diff --git a/backend/config/cloudinary.js b/backend/config/cloudinary.js new file mode 100644 index 0000000..ea9e7e6 --- /dev/null +++ b/backend/config/cloudinary.js @@ -0,0 +1,9 @@ +const cloudinary = require("cloudinary").v2; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +module.exports = cloudinary; \ No newline at end of file diff --git a/backend/config/db.js b/backend/config/db.js index 369ce73..b38b77a 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -1,11 +1,15 @@ // config/db.js const mongoose = require("mongoose"); -if (!process.env.MONGO_URI) { - throw new Error("Missing MONGO_URI environment variable"); -} - const connectDB = async () => { + if (!process.env.MONGO_URI) { + if (process.env.NODE_ENV === "test") { + return; + } + + throw new Error("Missing MONGO_URI environment variable"); + } + let retries = 1; const delay = 1000; // 1s backoff if you want to bump retries later diff --git a/backend/models/AssignmentSubmission.js b/backend/models/AssignmentSubmission.js new file mode 100644 index 0000000..407ec9a --- /dev/null +++ b/backend/models/AssignmentSubmission.js @@ -0,0 +1,107 @@ +const mongoose = require('mongoose'); + +const GradeSchema = new mongoose.Schema({ + points: { type: Number, required: true }, + maxPoints: { type: Number, required: true }, + comment: { type: String, default: '' } +}, { _id: false }); + +const AssignmentSubmissionSchema = new mongoose.Schema({ + // Student Info + student: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + studentName: { type: String, required: true }, + studentEmail: { type: String, required: true }, + + // Course Info + course: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Course', + required: true + }, + courseName: { type: String, required: true }, + + module: { + type: mongoose.Schema.Types.ObjectId, + required: true + }, + moduleName: { type: String, required: true }, + + // Assignment Details + lessonId: { type: String, required: true }, // The generated lesson ID + assignmentTitle: { type: String, required: true }, + assignmentType: { + type: String, + enum: ['question-based', 'part-based'], + default: 'part-based' + }, + + // OLD: Part-based assignment data + assignmentData: { + parts: [{ + partNumber: Number, + title: String, + instructions: String + }], + gradingCriteria: [{ + name: String, + points: Number + }] + }, + + // Student Submission + submittedAt: { type: Date, default: Date.now }, + + // OLD: Part-based answers + partAnswers: { + type: Map, + of: String, + default: {} + }, + + // NEW: Question-based answers + answers: { + type: Map, + of: mongoose.Schema.Types.Mixed, + default: {} + }, + + fileUrl: { type: String, default: '' }, + + // Grading + status: { + type: String, + enum: ['pending', 'graded'], + default: 'pending' + }, + grades: { + type: Map, + of: GradeSchema, + default: {} + }, + totalScore: { type: Number, default: 0 }, + maxScore: { type: Number, required: true }, + passed: { type: Boolean, default: false }, + passingScore: { type: Number, default: 70 }, // percentage + overallFeedback: { type: String, default: '' }, + gradedAt: { type: Date }, + + // Instructor + instructor: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + } +}, { + timestamps: true +}); + +// Index for quick queries +AssignmentSubmissionSchema.index({ instructor: 1, status: 1 }); +AssignmentSubmissionSchema.index({ student: 1, course: 1 }); +AssignmentSubmissionSchema.index({ course: 1, status: 1 }); + +module.exports = mongoose.model('AssignmentSubmission', AssignmentSubmissionSchema); \ No newline at end of file diff --git a/backend/models/CourseModel.js b/backend/models/CourseModel.js index db850e0..6f334cb 100644 --- a/backend/models/CourseModel.js +++ b/backend/models/CourseModel.js @@ -1,10 +1,12 @@ const mongoose = require("mongoose"); const InstructorSchema = new mongoose.Schema({ + _id: { type: mongoose.Schema.Types.ObjectId, required: true }, // ADD THIS LINE name: { type: String, required: true }, title: { type: String, default: "" }, bio: { type: String, default: "" }, - avatar: { type: String, default: "" } + avatar: { type: String, default: "" }, + email: { type: String, default: "" } }, { _id: false }); const PricingSchema = new mongoose.Schema({ @@ -22,27 +24,167 @@ const SubscriptionSchema = new mongoose.Schema({ tier: { type: String, default: "" } }, { _id: false }); +// NEW: Learning Materials Schema +const LearningMaterialsSchema = new mongoose.Schema({ + readings: [{ + title: { type: String }, + author: { type: String }, + citation: { type: String }, + link: { type: String } + }], + podcasts: [{ + title: { type: String }, + link: { type: String } + }], + videos: [{ + title: { type: String }, + link: { type: String } + }] +}, { _id: false }); + +// NEW: Assignment Question Schema with different types +const AssignmentQuestionSchema = new mongoose.Schema({ + questionNumber: { type: Number, required: true }, + type: { + type: String, + enum: ['multiple-choice', 'written', 'matching', 'pdf-upload', 'true-false'], + required: true + }, + + // Common fields + question: { type: String, required: true }, + points: { type: Number, default: 10 }, + + // For multiple-choice + options: [{ type: String }], + correctAnswer: { type: Number }, // index of correct option + + // For matching + matchPairs: [{ + left: { type: String }, + right: { type: String } + }], + + // For written response + wordLimit: { type: Number }, + rubric: { type: String }, + + // For PDF upload + fileRequirements: { type: String } + +}, { _id: false }); + +// Updated Assignment Schema +const AssignmentSchema = new mongoose.Schema({ + title: { type: String, required: true }, + purpose: { type: String }, + instructions: { type: String }, + + // NEW: Question-based structure + questions: [AssignmentQuestionSchema], + + // DEPRECATED: Old part-based structure (keep for backward compatibility) + parts: [{ + partNumber: { type: Number }, + title: { type: String }, + instructions: { type: String } + }], + + gradingCriteria: [{ + name: { type: String }, + points: { type: Number } + }], + + deliverableFormat: { type: String }, + totalPoints: { type: Number, default: 100 } + +}, { _id: false }); + +// Lesson Schema (videos, assignments, quizzes within a module) +const LessonSchema = new mongoose.Schema({ + type: { + type: String, + enum: ["video", "assignment", "quiz"], + required: true + }, + title: { type: String, required: true }, + order: { type: Number, required: true }, + + // For VIDEO lessons + videoUrl: { type: String, default: "" }, + duration: { type: String, default: "" }, + + // For ASSIGNMENT lessons + assignmentType: { + type: String, + enum: ["text", "file", "both"], + default: "text" + }, + instructions: { type: String, default: "" }, + + // For QUIZ lessons + questions: [{ + question: { type: String }, + questionType: { + type: String, + enum: ["multiple-choice", "short-answer"], + default: "multiple-choice" + }, + options: [{ type: String }], // for multiple choice + correctAnswer: { type: String }, // answer text or option index + points: { type: Number, default: 1 } + }], + passingScore: { type: Number, default: 70 }, // percentage + +}, { _id: true }); + const ModuleSchema = new mongoose.Schema({ title: { type: String, required: true }, description: { type: String, default: "" }, - - // Content types + order: { type: Number, default: 0 }, + + // NEW: Academic structure + learningOutcomes: { type: [String], default: [] }, + learningMaterials: { type: LearningMaterialsSchema, default: () => ({ readings: [], podcasts: [], videos: [] }) }, + assignment: { type: AssignmentSchema, default: null }, + + // Array of lessons (kept for compatibility) + lessons: { type: [LessonSchema], default: [] }, + + // DEPRECATED: Old fields kept for backward compatibility videoUrl: { type: String, default: "" }, articleContent: { type: String, default: "" }, pdfUrl: { type: String, default: "" }, - - // Quiz placeholder for future hasQuiz: { type: Boolean, default: false }, quizData: { type: Object, default: null }, - learningPoints: { type: [String], default: [] }, duration: { type: String, default: "" }, estimatedMinutes: { type: Number, default: 0 }, thumbnail: { type: String, default: "" }, - - order: { type: Number, default: 0 } }, { _id: true }); +// Final Test Schema +const FinalTestSchema = new mongoose.Schema({ + title: { type: String, default: "Final Test" }, + description: { type: String, default: "" }, + passingScore: { type: Number, default: 70 }, + questions: [{ + question: { type: String, required: true }, + options: [{ type: String }], + correctAnswer: { type: Number }, + points: { type: Number, default: 1 } + }], + timeLimit: { type: Number, default: 0 }, +}, { _id: false }); + +// Course Badge Schema +const BadgeSchema = new mongoose.Schema({ + name: { type: String, default: "" }, + description: { type: String, default: "" }, + imageUrl: { type: String, default: "" }, + color: { type: String, default: "#4F46E5" } +}, { _id: false }); + const CourseSchema = new mongoose.Schema({ title: { type: String, required: true }, description: { type: String, required: true }, @@ -75,7 +217,16 @@ const CourseSchema = new mongoose.Schema({ learningPoints: { type: [String], default: [] }, modules: { type: [ModuleSchema], default: [] }, - + + finalTest: { + type: FinalTestSchema, + default: null + }, + + badge: { + type: BadgeSchema, + default: () => ({}) + }, }, { timestamps: true, @@ -83,7 +234,6 @@ const CourseSchema = new mongoose.Schema({ ); CourseSchema.virtual("priceAmount").get(function () { - // 1) If pricing.amount is set, use that if ( this.pricing && typeof this.pricing.amount === "number" && @@ -92,7 +242,6 @@ CourseSchema.virtual("priceAmount").get(function () { return this.pricing.amount; } - // 2) If the raw document (e.g., created via Atlas) has priceAmount, use it if (this._doc && typeof this._doc.priceAmount === "number") { return this._doc.priceAmount; } @@ -100,17 +249,20 @@ CourseSchema.virtual("priceAmount").get(function () { return 0; }); -// Virtual: whether the course counts as free CourseSchema.virtual("isFree").get(function () { const price = this.priceAmount; return this.isLiteVersion || price === 0; }); -// Make sure virtuals show up in JSON sent to frontend +CourseSchema.virtual("totalLessons").get(function () { + return this.modules.reduce((total, module) => { + return total + (module.lessons?.length || 0); + }, 0); +}); + CourseSchema.set("toJSON", { virtuals: true }); CourseSchema.set("toObject", { virtuals: true }); -// Indexes to support search & filtering (optional but good to add) CourseSchema.index({ title: "text", description: "text", @@ -121,4 +273,4 @@ CourseSchema.index({ category: 1 }); CourseSchema.index({ "pricing.amount": 1 }); CourseSchema.index({ createdAt: -1 }); -module.exports = mongoose.model("Course", CourseSchema); +module.exports = mongoose.model("Course", CourseSchema); \ No newline at end of file diff --git a/backend/models/SeminarModel.js b/backend/models/SeminarModel.js index 20133aa..a520d20 100644 --- a/backend/models/SeminarModel.js +++ b/backend/models/SeminarModel.js @@ -23,9 +23,14 @@ const SeminarSchema = new mongoose.Schema({ // Scheduling info schedule: { - date: { type: String }, // YYYY-MM-DD - time: { type: String }, // HH:MM - joinUrl: { type: String } + date: { type: String }, // Legacy: YYYY-MM-DD + time: { type: String }, // Legacy: HH:MM + joinUrl: { type: String }, + startAt: { type: Date }, + endAt: { type: Date }, + sourceTimezone: { type: String }, + zoomMeetingId: { type: String }, + zoomPassword: { type: String } }, createdAt: { type: Date, default: Date.now } diff --git a/backend/models/TutorialModel.js b/backend/models/TutorialModel.js index 8b86343..be97635 100644 --- a/backend/models/TutorialModel.js +++ b/backend/models/TutorialModel.js @@ -12,6 +12,11 @@ const TutorialSchema = new mongoose.Schema({ videoUrl: { type: String }, // YouTube, Vimeo, etc. writtenContent: { type: String }, // Long text section + // INSTRUCTOR + instructor: { + name: { type: String, required: true } + }, + // Downloadable Resources resources: [ { diff --git a/backend/models/UserModel.js b/backend/models/UserModel.js index fbbbe26..2e0c5b5 100644 --- a/backend/models/UserModel.js +++ b/backend/models/UserModel.js @@ -1,7 +1,77 @@ const mongoose = require("mongoose"); -const bcrypt = require("bcrypt"); // <-- NEW - -const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS || 10); // <-- NEW +const bcrypt = require("bcrypt"); + +const SALT_ROUNDS = Number(process.env.BCRYPT_SALT_ROUNDS || 10); + +// Assignment Submission Schema +const AssignmentSubmissionSchema = new mongoose.Schema({ + lessonId: { type: mongoose.Schema.Types.Mixed, required: true }, + textSubmission: { type: String, default: "" }, + fileUrl: { type: String, default: "" }, + submittedAt: { type: Date, default: Date.now }, + grade: { type: Number, default: null }, + feedback: { type: String, default: "" } +}, { _id: false }); + +// Quiz Result Schema +const QuizResultSchema = new mongoose.Schema({ + lessonId: { type: mongoose.Schema.Types.Mixed, required: true }, + score: { type: Number, required: true }, // percentage + totalQuestions: { type: Number, required: true }, + correctAnswers: { type: Number, required: true }, + answers: [{ type: mongoose.Schema.Types.Mixed }], // user's answers + passed: { type: Boolean, required: true }, + attemptedAt: { type: Date, default: Date.now } +}, { _id: false }); + +// Course Progress Schema +const CourseProgressSchema = new mongoose.Schema({ + courseId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Course", + required: true + }, + + // Tracking completed lessons (can be ObjectIds OR custom strings like "podcast-0") + completedLessons: [{ + type: mongoose.Schema.Types.Mixed // ← CHANGED from ObjectId to Mixed + }], + + // Current position in course (can be ObjectIds OR custom strings) + currentModuleId: { type: mongoose.Schema.Types.Mixed }, + currentLessonId: { type: mongoose.Schema.Types.Mixed }, + + // Assignment submissions + assignmentSubmissions: [AssignmentSubmissionSchema], + + // Quiz results + quizResults: [QuizResultSchema], + + // Final test + finalTestScore: { type: Number, default: null }, + finalTestPassed: { type: Boolean, default: false }, + finalTestAttempts: { type: Number, default: 0 }, + + // Completion tracking + isCompleted: { type: Boolean, default: false }, + completedAt: { type: Date, default: null }, + badgeEarned: { type: Boolean, default: false }, + + // Progress percentage + progressPercentage: { type: Number, default: 0 }, + + lastAccessedAt: { type: Date, default: Date.now } +}, { _id: true }); + +// Learning Time Tracker (overall + per-course) +const LearningByCourseSchema = new mongoose.Schema( + { + courseId: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true }, + totalSeconds: { type: Number, default: 0, min: 0 }, + lastTrackedAt: { type: Date, default: Date.now } + }, + { _id: false } +); const UserSchema = new mongoose.Schema({ // Basic Identity @@ -24,10 +94,10 @@ const UserSchema = new mongoose.Schema({ unique: true }, - password: { type: String, required: true }, // now stored as hash + password: { type: String, required: true }, // User Permissions - role: { + accountType: { type: String, enum: ["student", "instructor", "admin"], default: "student", @@ -42,6 +112,9 @@ const UserSchema = new mongoose.Schema({ { type: mongoose.Schema.Types.ObjectId, ref: "Course" } ], + // Detailed course progress tracking + courseProgress: [CourseProgressSchema], + registeredSeminars: [ { type: mongoose.Schema.Types.ObjectId, ref: "Seminar" } ], @@ -57,15 +130,20 @@ const UserSchema = new mongoose.Schema({ savedPodcasts: [ { type: mongoose.Schema.Types.ObjectId, ref: "Podcast" } ], + + // Total Learning Time Tracker (store seconds; convert to hours in UI) + learning: { + totalSeconds: { type: Number, default: 0, min: 0 }, + byCourse: { type: [LearningByCourseSchema], default: [] }, + updatedAt: { type: Date, default: Date.now } + }, }, { timestamps: true, } ); -// ------------------------------------------------------ // Password hashing -// ------------------------------------------------------ UserSchema.pre("save", async function (next) { if (!this.isModified("password")) return next(); @@ -78,10 +156,12 @@ UserSchema.pre("save", async function (next) { } }); -// Fast lookups for login + registration -UserSchema.index({ role: 1 }); +// Fast lookups +UserSchema.index({ accountType: 1 }); UserSchema.index({ enrolledCourses: 1 }); UserSchema.index({ completedCourses: 1 }); UserSchema.index({ bookmarkedTutorials: 1 }); +UserSchema.index({ "courseProgress.courseId": 1 }); +UserSchema.index({ "learning.byCourse.courseId": 1 }); module.exports = mongoose.model("User", UserSchema); diff --git a/backend/package-lock.json b/backend/package-lock.json index 0db78c9..4686f55 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "bytes": "^3.1.2", "call-bind-apply-helpers": "^1.0.2", "call-bound": "^1.0.4", + "cloudinary": "^2.9.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.2", @@ -52,6 +53,7 @@ "mime-types": "^3.0.1", "mongoose": "^8.19.3", "ms": "^2.1.3", + "multer": "^2.1.1", "negotiator": "^1.0.0", "object-assign": "^4.1.1", "on-finished": "^2.4.1", @@ -80,9 +82,20 @@ "wrappy": "^1.0.2" }, "devDependencies": { + "concurrently": "^8.2.2", "nodemon": "^3.1.10" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.3.2", "license": "MIT", @@ -112,6 +125,32 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "dev": true, @@ -124,6 +163,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -219,6 +264,23 @@ "version": "1.0.1", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -251,6 +313,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "dev": true, @@ -274,11 +376,127 @@ "fsevents": "~2.3.2" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cloudinary": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.9.0.tgz", + "integrity": "sha512-F3iKMOy4y0zy0bi5JBp94SC7HY7i/ImfTPSUV07iJmRzH1Iz8WavFfOlJTR1zvYM/xKGoiGZ3my/zy64In0IQQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=9" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "license": "MIT", @@ -339,6 +557,23 @@ "node": ">= 0.10" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -394,6 +629,13 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "license": "MIT", @@ -425,6 +667,16 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "license": "MIT" @@ -538,6 +790,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -683,6 +945,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -750,6 +1022,12 @@ "node": ">=12.0.0" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "license": "MIT" @@ -920,6 +1198,68 @@ "version": "2.1.3", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "license": "MIT", @@ -1102,6 +1442,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -1113,6 +1467,16 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "license": "MIT", @@ -1127,6 +1491,16 @@ "node": ">= 18" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "funding": [ @@ -1196,6 +1570,19 @@ "version": "1.2.0", "license": "ISC" }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "license": "MIT", @@ -1322,6 +1709,12 @@ "memory-pager": "^1.0.2" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/statuses": { "version": "2.0.2", "license": "MIT", @@ -1329,6 +1722,51 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stripe": { "version": "20.1.2", "resolved": "https://registry.npmmirror.com/stripe/-/stripe-20.1.2.tgz", @@ -1396,6 +1834,23 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-is": { "version": "2.0.1", "license": "MIT", @@ -1408,6 +1863,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "dev": true, @@ -1420,6 +1881,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "license": "MIT", @@ -1445,9 +1912,66 @@ "node": ">=18" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/backend/package.json b/backend/package.json index bd818b9..b0c5086 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,12 @@ "main": "server.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js" + "start": "node server.js", + "server": "nodemon server.js", + "client": "npm start --prefix ../frontend", + "client-build": "npm run build --prefix ../frontend", + "build": "npm install && npm install --prefix ../frontend && npm run build --prefix ../frontend", + "dev": "concurrently \"npm run server\" \"npm run client\"" }, "keywords": [], "author": "", @@ -18,6 +23,7 @@ "bytes": "^3.1.2", "call-bind-apply-helpers": "^1.0.2", "call-bound": "^1.0.4", + "cloudinary": "^2.9.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.2", @@ -55,6 +61,7 @@ "mime-types": "^3.0.1", "mongoose": "^8.19.3", "ms": "^2.1.3", + "multer": "^2.1.1", "negotiator": "^1.0.0", "object-assign": "^4.1.1", "on-finished": "^2.4.1", @@ -83,6 +90,7 @@ "wrappy": "^1.0.2" }, "devDependencies": { + "concurrently": "^8.2.2", "nodemon": "^3.1.10" } } diff --git a/backend/routes/assignments.js b/backend/routes/assignments.js new file mode 100644 index 0000000..e6f4b52 --- /dev/null +++ b/backend/routes/assignments.js @@ -0,0 +1,124 @@ +const express = require('express'); +const router = express.Router(); +const AssignmentSubmission = require('../models/AssignmentSubmission'); +const Course = require('../models/CourseModel'); +const User = require('../models/UserModel'); +const { protect } = require('../middleware/authMiddleware'); + +// ------------------------------------- +// POST - Submit Assignment +// ------------------------------------- +router.post('/submit', protect, async (req, res) => { + try { + const { courseId, moduleId, assignmentId, answers, fileUrl, submittedAt } = req.body; + + console.log('=== ASSIGNMENT SUBMISSION ==='); + console.log('User:', req.user); + console.log('Body:', req.body); + + if (!courseId || !moduleId || !assignmentId) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + // Get course and module to extract assignment data + const course = await Course.findById(courseId); + if (!course) { + return res.status(404).json({ error: 'Course not found' }); + } + + const module = course.modules.id(moduleId); + if (!module) { + return res.status(404).json({ error: 'Module not found' }); + } + + const assignment = module.assignment; + if (!assignment) { + return res.status(404).json({ error: 'Assignment not found' }); + } + + // Get student info + const student = await User.findById(req.user._id); + if (!student) { + return res.status(404).json({ error: 'Student not found' }); + } + + // Determine if question-based or part-based + const isQuestionBased = assignment.questions && assignment.questions.length > 0; + + // Create submission with ALL required fields + const submission = new AssignmentSubmission({ + student: req.user._id, + studentName: `${student.firstName} ${student.lastName}`, + studentEmail: student.email, + + course: courseId, + courseName: course.title, + + module: moduleId, + moduleName: module.title, + + lessonId: assignmentId, + assignmentTitle: assignment.title || 'Module Assignment', + assignmentType: isQuestionBased ? 'question-based' : 'part-based', + + // Assignment data (for backward compatibility) + assignmentData: { + parts: assignment.parts || [], + gradingCriteria: assignment.gradingCriteria || [] + }, + + // Submission data + answers: answers || {}, + partAnswers: new Map(), // Empty for new submissions + fileUrl: fileUrl || '', + submittedAt: submittedAt || new Date(), + + // Grading info + maxScore: assignment.totalPoints || 100, + passingScore: 70, + status: 'pending', + + instructor: course.instructor._id + }); + + await submission.save(); + + console.log('✅ Submission saved:', submission._id); + + res.status(201).json({ + message: 'Assignment submitted successfully', + submission + }); + + } catch (err) { + console.error('❌ Error submitting assignment:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET - Get student's submission for an assignment +// ------------------------------------- +router.get('/:courseId/:assignmentId', protect, async (req, res) => { + try { + const { courseId, assignmentId } = req.params; + + const submission = await AssignmentSubmission.findOne({ + student: req.user._id, + course: courseId, + lessonId: assignmentId + }); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found' }); + } + + res.json(submission); + + } catch (err) { + console.error('Error fetching submission:', err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/courseProgress.js b/backend/routes/courseProgress.js new file mode 100644 index 0000000..ce5acfe --- /dev/null +++ b/backend/routes/courseProgress.js @@ -0,0 +1,329 @@ +const express = require("express"); +const User = require("../models/UserModel"); +const Course = require("../models/CourseModel"); +const AssignmentSubmission = require("../models/AssignmentSubmission"); +const { protect } = require("../middleware/authMiddleware"); + +const router = express.Router(); + +// ============================================ +// GET course progress for a user +// ============================================ +router.get("/:courseId/progress", protect, async (req, res) => { + try { + const user = await User.findById(req.user._id); + const progress = user.courseProgress.find( + p => p.courseId.toString() === req.params.courseId + ); + + if (!progress) { + return res.json({ + courseId: req.params.courseId, + completedLessons: [], + progressPercentage: 0, + currentModuleId: null, + currentLessonId: null + }); + } + + res.json(progress); + } catch (err) { + console.error("Error fetching progress:", err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// MARK lesson as complete +// ============================================ +router.post("/:courseId/progress/lesson/:lessonId/complete", protect, async (req, res) => { + try { + const { courseId, lessonId } = req.params; + + const user = await User.findById(req.user._id); + const course = await Course.findById(courseId); + + if (!course) { + return res.status(404).json({ error: "Course not found" }); + } + + // Find or create progress entry + let progress = user.courseProgress.find( + p => p.courseId.toString() === courseId + ); + + if (!progress) { + progress = { + courseId, + completedLessons: [], + assignmentSubmissions: [], + quizResults: [], + lastAccessedAt: new Date() + }; + user.courseProgress.push(progress); + } + + // Add lesson to completed if not already there + // Convert lessonId to string for comparison since it might be a custom ID like "podcast-0" + const lessonIdStr = lessonId.toString(); + if (!progress.completedLessons.some(id => id.toString() === lessonIdStr)) { + progress.completedLessons.push(lessonId); + } + + // Update last accessed + progress.lastAccessedAt = new Date(); + + // Calculate progress percentage + const totalLessons = course.modules.reduce((total, module) => { + let count = module.lessons?.length || 0; + // Add learning materials + count += module.learningMaterials?.readings?.length || 0; + count += module.learningMaterials?.podcasts?.length || 0; + count += module.learningMaterials?.videos?.length || 0; + // Add assignment if exists + if (module.assignment) count += 1; + return total + count; + }, 0); + + progress.progressPercentage = totalLessons > 0 + ? Math.round((progress.completedLessons.length / totalLessons) * 100) + : 0; + + await user.save(); + + res.json({ + message: "Lesson marked as complete", + progress: progress + }); + + } catch (err) { + console.error("Error marking lesson complete:", err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// UPDATE current position (for "continue where you left off") +// ============================================ +router.post("/:courseId/progress/position", protect, async (req, res) => { + try { + const { courseId } = req.params; + const { moduleId, lessonId } = req.body; + + console.log('\n=== UPDATE POSITION REQUEST ==='); + console.log('Course ID:', courseId); + console.log('Module ID:', moduleId); + console.log('Lesson ID:', lessonId); + + const user = await User.findById(req.user._id); + + let progress = user.courseProgress.find( + p => p.courseId.toString() === courseId + ); + + if (!progress) { + // Create new progress entry + progress = { + courseId, + completedLessons: [], + assignmentSubmissions: [], + quizResults: [], + currentModuleId: moduleId || null, + currentLessonId: lessonId || null, + lastAccessedAt: new Date() + }; + user.courseProgress.push(progress); + console.log('Created new progress entry'); + } else { + // Update existing progress + // Store as strings since lessonId might be custom IDs like "podcast-0" + progress.currentModuleId = moduleId || progress.currentModuleId; + progress.currentLessonId = lessonId || progress.currentLessonId; + progress.lastAccessedAt = new Date(); + console.log('Updated existing progress'); + } + + await user.save(); + console.log('Position saved successfully'); + + res.json({ message: "Position updated", progress }); + + } catch (err) { + console.error('\n❌ ERROR updating position:'); + console.error('Message:', err.message); + console.error('Stack:', err.stack); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// SUBMIT quiz +// ============================================ +router.post("/:courseId/progress/quiz/:lessonId/submit", protect, async (req, res) => { + try { + const { courseId, lessonId } = req.params; + const { answers } = req.body; + + const user = await User.findById(req.user._id); + const course = await Course.findById(courseId); + + if (!course) { + return res.status(404).json({ error: "Course not found" }); + } + + // Find the quiz lesson + let quizLesson = null; + for (const module of course.modules) { + const lesson = module.lessons.find(l => l._id.toString() === lessonId); + if (lesson) { + quizLesson = lesson; + break; + } + } + + if (!quizLesson || quizLesson.type !== 'quiz') { + return res.status(404).json({ error: "Quiz not found" }); + } + + // Grade the quiz + let correctAnswers = 0; + const totalQuestions = quizLesson.questions.length; + + quizLesson.questions.forEach((question, index) => { + const userAnswer = answers[index]; + + if (question.questionType === 'multiple-choice') { + if (userAnswer === question.correctAnswer) { + correctAnswers++; + } + } else if (question.questionType === 'short-answer') { + if (userAnswer?.toLowerCase().trim() === question.correctAnswer?.toLowerCase().trim()) { + correctAnswers++; + } + } + }); + + const score = Math.round((correctAnswers / totalQuestions) * 100); + const passed = score >= (quizLesson.passingScore || 70); + + let progress = user.courseProgress.find( + p => p.courseId.toString() === courseId + ); + + if (!progress) { + progress = { + courseId, + completedLessons: [], + assignmentSubmissions: [], + quizResults: [], + lastAccessedAt: new Date() + }; + user.courseProgress.push(progress); + } + + // Save quiz result + const quizResult = { + lessonId, + score, + totalQuestions, + correctAnswers, + answers, + passed, + attemptedAt: new Date() + }; + + progress.quizResults.push(quizResult); + + // Mark as complete if passed + if (passed && !progress.completedLessons.some(id => id.toString() === lessonId)) { + progress.completedLessons.push(lessonId); + } + + progress.lastAccessedAt = new Date(); + + await user.save(); + + res.json({ + message: passed ? "Quiz passed!" : "Quiz completed. Try again!", + result: quizResult + }); + + } catch (err) { + console.error("Error submitting quiz:", err); + res.status(500).json({ error: err.message }); + } +}); + +// ============================================ +// SUBMIT final test +// ============================================ +router.post("/:courseId/progress/test/submit", protect, async (req, res) => { + try { + const { courseId } = req.params; + const { answers } = req.body; + + const user = await User.findById(req.user._id); + const course = await Course.findById(courseId); + + if (!course || !course.finalTest) { + return res.status(404).json({ error: "Final test not found" }); + } + + // Grade the test + let correctAnswers = 0; + const totalQuestions = course.finalTest.questions.length; + + course.finalTest.questions.forEach((question, index) => { + const userAnswer = answers[index]; + if (userAnswer === question.correctAnswer) { + correctAnswers++; + } + }); + + const score = Math.round((correctAnswers / totalQuestions) * 100); + const passed = score >= (course.finalTest.passingScore || 70); + + let progress = user.courseProgress.find( + p => p.courseId.toString() === courseId + ); + + if (!progress) { + return res.status(400).json({ error: "No progress found for this course" }); + } + + // Update test results + progress.finalTestScore = score; + progress.finalTestPassed = passed; + progress.finalTestAttempts = (progress.finalTestAttempts || 0) + 1; + progress.lastAccessedAt = new Date(); + + // If passed, mark course as complete + if (passed) { + progress.isCompleted = true; + progress.completedAt = new Date(); + progress.badgeEarned = true; + progress.progressPercentage = 100; + + // Add to completedCourses if not already there + if (!user.completedCourses.includes(courseId)) { + user.completedCourses.push(courseId); + } + } + + await user.save(); + + res.json({ + message: passed ? "Congratulations! You've completed the course!" : "Test completed. Keep trying!", + score, + passed, + badgeEarned: passed, + badge: passed ? course.badge : null + }); + + } catch (err) { + console.error("Error submitting final test:", err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/courses.js b/backend/routes/courses.js index a7a90d7..fb20685 100644 --- a/backend/routes/courses.js +++ b/backend/routes/courses.js @@ -1,5 +1,8 @@ const express = require("express"); const Course = require("../models/CourseModel"); +const User = require("../models/UserModel"); +const { protect } = require("../middleware/authMiddleware"); +const testCourses = require("../data/courses.json"); const router = express.Router(); @@ -8,6 +11,10 @@ const router = express.Router(); // ------------------------------------- router.get("/", async (req, res) => { try { + if (process.env.NODE_ENV === "test" && !process.env.MONGO_URI) { + return res.json(testCourses); + } + const courses = await Course.find().sort({ createdAt: -1 }); res.json(courses); } catch (err) { @@ -21,6 +28,16 @@ router.get("/", async (req, res) => { // ------------------------------------- router.get("/:id", async (req, res) => { try { + if (process.env.NODE_ENV === "test" && !process.env.MONGO_URI) { + const course = testCourses.find((item) => item._id === req.params.id); + + if (!course) { + return res.status(404).json({ message: "Course not found" }); + } + + return res.json(course); + } + const course = await Course.findById(req.params.id); if (!course) { @@ -37,12 +54,34 @@ router.get("/:id", async (req, res) => { // ------------------------------------- // CREATE a course // ------------------------------------- -router.post("/", async (req, res) => { +router.post("/", protect, async (req, res) => { try { console.log("Incoming Create Course Request:"); console.log(JSON.stringify(req.body, null, 2)); - const course = new Course(req.body); + const instructor = await User.findById(req.user._id).select( + "firstName lastName email accountType" + ); + + if (!instructor) { + return res.status(404).json({ error: "Instructor not found" }); + } + + if (instructor.accountType !== "instructor") { + return res.status(403).json({ error: "Only instructors can create courses" }); + } + + const course = new Course({ + ...req.body, + instructor: { + _id: instructor._id, + name: `${instructor.firstName} ${instructor.lastName}`.trim(), + email: instructor.email, + title: req.body.instructor?.title || "Instructor", + bio: req.body.instructor?.bio || "", + avatar: req.body.instructor?.avatar || "", + }, + }); const saved = await course.save(); console.log("Saved Course:", saved); @@ -58,18 +97,23 @@ router.post("/", async (req, res) => { // ------------------------------------- // UPDATE a course // ------------------------------------- -router.put("/:id", async (req, res) => { +router.put("/:id", protect, async (req, res) => { try { + const course = await Course.findById(req.params.id); + if (!course) { + return res.status(404).json({ error: "Course not found" }); + } + + if (course.instructor?._id?.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only edit your own courses" }); + } + const updated = await Course.findByIdAndUpdate( req.params.id, req.body, { new: true, runValidators: true } ); - if (!updated) { - return res.status(404).json({ error: "Course not found" }); - } - res.json(updated); } catch (err) { console.error("ERROR UPDATING COURSE:", err); @@ -80,14 +124,18 @@ router.put("/:id", async (req, res) => { // ------------------------------------- // DELETE a course // ------------------------------------- -router.delete("/:id", async (req, res) => { +router.delete("/:id", protect, async (req, res) => { try { - const deleted = await Course.findByIdAndDelete(req.params.id); - - if (!deleted) { + const course = await Course.findById(req.params.id); + if (!course) { return res.status(404).json({ error: "Course not found" }); } + if (course.instructor?._id?.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only delete your own courses" }); + } + + await Course.findByIdAndDelete(req.params.id); res.json({ message: "Course deleted" }); } catch (err) { console.error("ERROR DELETING COURSE:", err); diff --git a/backend/routes/instructor.js b/backend/routes/instructor.js new file mode 100644 index 0000000..247fb6a --- /dev/null +++ b/backend/routes/instructor.js @@ -0,0 +1,363 @@ +const express = require('express'); +const router = express.Router(); +const Course = require('../models/CourseModel'); +const AssignmentSubmission = require('../models/AssignmentSubmission'); +const User = require('../models/UserModel'); +const { protect } = require('../middleware/authMiddleware'); + +// Middleware to check if user is an instructor +const isInstructor = async (req, res, next) => { + try { + // req.user is already set by the protect middleware + if (!req.user || req.user.accountType !== 'instructor') { + return res.status(403).json({ error: 'Access denied. Instructor account required.' }); + } + next(); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + +// ------------------------------------- +// GET instructor dashboard stats +// ------------------------------------- +router.get('/dashboard/stats', protect, isInstructor, async (req, res) => { + try { + const instructorId = req.user._id; + + // Get all courses by this instructor + const courses = await Course.find({ 'instructor._id': instructorId }); + const courseIds = courses.map(c => c._id); + + // Count total students (unique enrollments across all courses) + const allEnrolledUsers = await User.find({ + enrolledCourses: { $in: courseIds } + }); + const totalStudents = allEnrolledUsers.length; + + // Count pending submissions + const pendingCount = await AssignmentSubmission.countDocuments({ + instructor: instructorId, + status: 'pending' + }); + + // Count total graded submissions + const gradedCount = await AssignmentSubmission.countDocuments({ + instructor: instructorId, + status: 'graded' + }); + + // Calculate average grade across all graded submissions + const gradedSubmissions = await AssignmentSubmission.find({ + instructor: instructorId, + status: 'graded' + }); + + let avgGrade = 0; + if (gradedSubmissions.length > 0) { + const totalPercentage = gradedSubmissions.reduce((sum, sub) => { + return sum + (sub.totalScore / sub.maxScore) * 100; + }, 0); + avgGrade = Math.round(totalPercentage / gradedSubmissions.length); + } + + res.json({ + totalCourses: courses.length, + totalStudents, + pendingSubmissions: pendingCount, + totalGraded: gradedCount, + averageGrade: avgGrade + }); + + } catch (err) { + console.error('Error fetching dashboard stats:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET all instructor's courses +// ------------------------------------- +router.get('/courses', protect, isInstructor, async (req, res) => { + try { + const instructorId = req.user._id; + + const courses = await Course.find({ 'instructor._id': instructorId }) + .sort({ createdAt: -1 }); + + // For each course, get enrollment count and pending submissions + const coursesWithStats = await Promise.all( + courses.map(async (course) => { + const enrolledCount = await User.countDocuments({ + enrolledCourses: course._id + }); + + const pendingCount = await AssignmentSubmission.countDocuments({ + course: course._id, + status: 'pending' + }); + + return { + ...course.toObject(), + enrolledCount, + pendingSubmissions: pendingCount + }; + }) + ); + + res.json(coursesWithStats); + + } catch (err) { + console.error('Error fetching instructor courses:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET pending submissions (FIXED - only one now) +// ------------------------------------- +router.get('/submissions/pending', protect, isInstructor, async (req, res) => { + try { + console.log('Fetching pending submissions for instructor:', req.user._id); + + const submissions = await AssignmentSubmission.find({ + instructor: req.user._id, + status: 'pending' + }) + .populate('student', 'firstName lastName email avatar') + .sort({ submittedAt: -1 }); + + console.log('Found pending submissions:', submissions.length); + + res.json(submissions); + + } catch (err) { + console.error('Error fetching pending submissions:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET all submissions (graded + pending) +// ------------------------------------- +router.get('/submissions', protect, isInstructor, async (req, res) => { + try { + const instructorId = req.user._id; + const { courseId, status } = req.query; + + const query = { instructor: instructorId }; + if (courseId) query.course = courseId; + if (status) query.status = status; + + const submissions = await AssignmentSubmission.find(query) + .sort({ submittedAt: -1 }) + .populate('student', 'firstName lastName email avatar') + .populate('course', 'title thumbnail'); + + res.json(submissions); + + } catch (err) { + console.error('Error fetching submissions:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET single submission for grading +// ------------------------------------- +// GET single submission for grading +router.get('/submissions/:id', protect, isInstructor, async (req, res) => { + try { + console.log('\n=== FETCHING SUBMISSION ==='); + console.log('Submission ID:', req.params.id); + + const submission = await AssignmentSubmission.findById(req.params.id) + .populate('student', 'firstName lastName email avatar') + .populate('course', 'title'); + + console.log('Submission found:', submission ? 'YES' : 'NO'); + + if (!submission) { + console.log('❌ Submission not found'); + return res.status(404).json({ error: 'Submission not found' }); + } + + console.log('Submission data:', { + student: submission.student, + course: submission.course, + assignmentType: submission.assignmentType, + hasAnswers: !!submission.answers, + hasPartAnswers: !!submission.partAnswers + }); + + // Verify this submission belongs to this instructor + if (submission.instructor.toString() !== req.user._id.toString()) { + console.log('❌ Access denied - wrong instructor'); + return res.status(403).json({ error: 'Access denied' }); + } + + console.log('✅ Returning submission to frontend'); + res.json(submission); + + } catch (err) { + console.error('\n❌ ERROR FETCHING SUBMISSION:'); + console.error('Message:', err.message); + console.error('Stack:', err.stack); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// POST grade submission +// ------------------------------------- +router.post('/submissions/:id/grade', protect, isInstructor, async (req, res) => { + try { + const { grades, overallFeedback } = req.body; + + const submission = await AssignmentSubmission.findById(req.params.id); + + if (!submission) { + return res.status(404).json({ error: 'Submission not found' }); + } + + // Verify this submission belongs to this instructor + if (submission.instructor.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Calculate total score + let totalScore = 0; + Object.values(grades).forEach(grade => { + totalScore += grade.points; + }); + + // Calculate if passed (based on percentage) + const percentage = (totalScore / submission.maxScore) * 100; + const passed = percentage >= submission.passingScore; + + // Update submission + submission.grades = grades; + submission.totalScore = totalScore; + submission.passed = passed; + submission.overallFeedback = overallFeedback; + submission.status = 'graded'; + submission.gradedAt = new Date(); + + await submission.save(); + + res.json({ + message: 'Submission graded successfully', + submission + }); + + } catch (err) { + console.error('Error grading submission:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET students for a specific course +// ------------------------------------- +router.get('/courses/:courseId/students', protect, isInstructor, async (req, res) => { + try { + const { courseId } = req.params; + + // Verify the course belongs to this instructor + const course = await Course.findById(courseId); + if (!course || course.instructor._id.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Get all enrolled students + const students = await User.find({ + enrolledCourses: courseId + }).select('firstName lastName username email avatar enrolledAt'); + + // For each student, get their progress + const studentsWithProgress = await Promise.all( + students.map(async (student) => { + // Get their submissions for this course + const submissions = await AssignmentSubmission.find({ + student: student._id, + course: courseId + }); + + const totalSubmissions = submissions.length; + const gradedSubmissions = submissions.filter(s => s.status === 'graded'); + const avgGrade = gradedSubmissions.length > 0 + ? Math.round( + gradedSubmissions.reduce((sum, s) => sum + (s.totalScore / s.maxScore) * 100, 0) / + gradedSubmissions.length + ) + : 0; + + return { + ...student.toObject(), + name: student.firstName && student.lastName + ? `${student.firstName} ${student.lastName}` + : student.username, + totalSubmissions, + gradedSubmissions: gradedSubmissions.length, + averageGrade: avgGrade + }; + }) + ); + + res.json(studentsWithProgress); + + } catch (err) { + console.error('Error fetching students:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ------------------------------------- +// GET course stats +// ------------------------------------- +router.get('/courses/:courseId/stats', protect, isInstructor, async (req, res) => { + try { + const { courseId } = req.params; + + const course = await Course.findById(courseId); + if (!course || course.instructor._id.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: 'Access denied' }); + } + + const enrolledCount = await User.countDocuments({ + enrolledCourses: courseId + }); + + const submissions = await AssignmentSubmission.find({ + course: courseId + }); + + const pendingCount = submissions.filter(s => s.status === 'pending').length; + const gradedCount = submissions.filter(s => s.status === 'graded').length; + + const gradedSubmissions = submissions.filter(s => s.status === 'graded'); + const avgGrade = gradedSubmissions.length > 0 + ? Math.round( + gradedSubmissions.reduce((sum, s) => sum + (s.totalScore / s.maxScore) * 100, 0) / + gradedSubmissions.length + ) + : 0; + + res.json({ + enrolledStudents: enrolledCount, + totalSubmissions: submissions.length, + pendingSubmissions: pendingCount, + gradedSubmissions: gradedCount, + averageGrade: avgGrade, + passRate: gradedSubmissions.length > 0 + ? Math.round((gradedSubmissions.filter(s => s.passed).length / gradedSubmissions.length) * 100) + : 0 + }); + + } catch (err) { + console.error('Error fetching course stats:', err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/learningRoutes.js b/backend/routes/learningRoutes.js new file mode 100644 index 0000000..c344cb9 --- /dev/null +++ b/backend/routes/learningRoutes.js @@ -0,0 +1,100 @@ +const express = require("express"); +const mongoose = require("mongoose"); +const User = require("../models/UserModel"); +const { protect } = require("../middleware/authMiddleware"); + +const router = express.Router(); + +/** + * POST /api/learning/track + * Body: { courseId, elapsedSec } + * Requires auth cookie (protect middleware) + */ +router.post("/track", protect, async (req, res) => { + try { + const userId = req.user._id; + const { courseId, elapsedSec } = req.body; + + const sec = Number(elapsedSec); + if (!Number.isFinite(sec) || sec <= 0) { + return res.status(400).json({ message: "elapsedSec must be a positive number" }); + } + + // Always update overall time + const baseUpdate = { + $inc: { "learning.totalSeconds": sec }, + $set: { "learning.updatedAt": new Date() } + }; + + // If no courseId provided, just update overall + if (!courseId) { + await User.updateOne({ _id: userId }, baseUpdate); + return res.json({ ok: true }); + } + + if (!mongoose.Types.ObjectId.isValid(courseId)) { + return res.status(400).json({ message: "Invalid courseId" }); + } + + // 1) Try to increment existing byCourse entry (positional update) + const updated = await User.updateOne( + { _id: userId, "learning.byCourse.courseId": courseId }, + { + ...baseUpdate, + $inc: { + ...baseUpdate.$inc, + "learning.byCourse.$.totalSeconds": sec + }, + $set: { + ...baseUpdate.$set, + "learning.byCourse.$.lastTrackedAt": new Date() + } + } + ); + + // 2) If no existing entry, push a new one + if (updated.matchedCount === 0) { + await User.updateOne( + { _id: userId }, + { + ...baseUpdate, + $push: { + "learning.byCourse": { + courseId, + totalSeconds: sec, + lastTrackedAt: new Date() + } + } + } + ); + } + + res.json({ ok: true }); + } catch (err) { + console.error("learning track error:", err); + res.status(500).json({ message: "Server error tracking learning time" }); + } +}); + +/** + * GET /api/learning/summary + * Returns totals for dashboard + */ +router.get("/summary", protect, async (req, res) => { + try { + const user = await User.findById(req.user._id).select("learning").lean(); + + res.json({ + totalSeconds: user?.learning?.totalSeconds ?? 0, + byCourse: (user?.learning?.byCourse ?? []).map(x => ({ + courseId: x.courseId, + totalSeconds: x.totalSeconds + })) + }); + } catch (err) { + console.error("learning summary error:", err); + res.status(500).json({ message: "Server error fetching learning summary" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/payments.js b/backend/routes/payments.js index 84a6a1f..54d33cd 100644 --- a/backend/routes/payments.js +++ b/backend/routes/payments.js @@ -1,50 +1,59 @@ const express = require("express"); const Stripe = require("stripe"); const Course = require("../models/CourseModel"); +const User = require("../models/UserModel"); +const { protect } = require("../middleware/authMiddleware"); const router = express.Router(); +const stripe = process.env.STRIPE_SECRET_KEY + ? new Stripe(process.env.STRIPE_SECRET_KEY) + : null; -if (!process.env.STRIPE_SECRET_KEY) { +if (!stripe) { console.warn("Stripe payments disabled: STRIPE_SECRET_KEY not set"); +} - router.post("/create-payment-intent", (req, res) => { - return res.status(501).json({ - error: "Payments are disabled in this environment", - }); - }); -} else { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +router.post("/create-payment-intent", protect, async (req, res) => { + const { courseId } = req.body; - router.post("/create-payment-intent", async (req, res) => { - const { courseId, userId } = req.body; + if (!courseId) { + return res.status(400).json({ error: "courseId required" }); + } - if (!courseId || !userId) { - return res.status(400).json({ error: "courseId and userId required" }); + try { + const course = await Course.findById(courseId); + if (!course) { + return res.status(404).json({ error: "Course not found" }); } - try { - const course = await Course.findById(courseId); - if (!course) { - return res.status(404).json({ error: "Course not found" }); - } - - if (course.isFree) { - return res.json({ free: true }); - } - - const paymentIntent = await stripe.paymentIntents.create({ - amount: Math.round(course.priceAmount * 100), - currency: "usd", - automatic_payment_methods: { enabled: true }, - metadata: { courseId, userId }, + if (course.isFree) { + await User.findByIdAndUpdate(req.user._id, { + $addToSet: { enrolledCourses: course._id }, }); + return res.json({ free: true }); + } - res.json({ clientSecret: paymentIntent.client_secret }); - } catch (err) { - console.error(err); - res.status(500).json({ error: "Payment failed" }); + if (!stripe) { + return res.status(501).json({ + error: "Payments are disabled in this environment", + }); } - }); -} + + const paymentIntent = await stripe.paymentIntents.create({ + amount: Math.round(course.priceAmount * 100), + currency: "usd", + automatic_payment_methods: { enabled: true }, + metadata: { + courseId: course._id.toString(), + userId: req.user._id.toString(), + }, + }); + + res.json({ clientSecret: paymentIntent.client_secret }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Payment failed" }); + } +}); module.exports = router; diff --git a/backend/routes/seminars.js b/backend/routes/seminars.js index d5019f1..c5910d7 100644 --- a/backend/routes/seminars.js +++ b/backend/routes/seminars.js @@ -1,7 +1,51 @@ const express = require("express"); +const jwt = require("jsonwebtoken"); const Seminar = require("../models/SeminarModel"); +const { protect } = require("../middleware/authMiddleware"); const router = express.Router(); +const LIVE_BUFFER_MS = 30 * 60 * 1000; + +const getSeminarStatus = (seminar, now = Date.now()) => { + const schedule = seminar?.schedule || {}; + const startAt = schedule.startAt ? new Date(schedule.startAt).getTime() : NaN; + const endAt = schedule.endAt ? new Date(schedule.endAt).getTime() : NaN; + + if (Number.isNaN(startAt) || Number.isNaN(endAt) || endAt <= startAt) { + return "Past"; + } + + if (now < startAt - LIVE_BUFFER_MS) return "Future"; + if (now > endAt + LIVE_BUFFER_MS) return "Past"; + return "Live Now"; +}; + +const normalizeSchedule = (schedule = {}) => { + const normalized = { ...schedule }; + + if (schedule.startAt) { + const start = new Date(schedule.startAt); + if (Number.isNaN(start.getTime())) { + throw new Error("Invalid schedule.startAt"); + } + normalized.startAt = start; + } + + if (schedule.endAt) { + const end = new Date(schedule.endAt); + if (Number.isNaN(end.getTime())) { + throw new Error("Invalid schedule.endAt"); + } + normalized.endAt = end; + } + + if (normalized.startAt && normalized.endAt && normalized.endAt <= normalized.startAt) { + throw new Error("schedule.endAt must be after schedule.startAt"); + } + + return normalized; +}; + // Get all seminars router.get("/", async (req, res) => { try { @@ -17,7 +61,12 @@ router.post("/", async (req, res) => { try { console.log("Incoming Seminar:", req.body); - const seminar = new Seminar(req.body); + const payload = { + ...req.body, + ...(req.body.schedule ? { schedule: normalizeSchedule(req.body.schedule) } : {}) + }; + + const seminar = new Seminar(payload); const saved = await seminar.save(); res.status(201).json(saved); @@ -38,12 +87,86 @@ router.get("/:id", async (req, res) => { } }); +// Zoom Meeting SDK signature for seminar join +router.get("/:id/zoom-signature", protect, async (req, res) => { + try { + const sdkKey = process.env.ZOOM_MEETING_SDK_KEY; + const sdkSecret = process.env.ZOOM_MEETING_SDK_SECRET; + + if (!sdkKey || !sdkSecret) { + return res.status(500).json({ message: "Zoom SDK credentials are not configured" }); + } + + const seminar = await Seminar.findById(req.params.id); + if (!seminar) { + return res.status(404).json({ message: "Seminar not found" }); + } + + const meetingNumber = seminar.schedule?.zoomMeetingId; + if (!meetingNumber) { + return res.status(400).json({ message: "Zoom meeting ID is missing for this seminar" }); + } + + const requestedMeetingNumber = req.query.meetingNumber; + if (requestedMeetingNumber && String(requestedMeetingNumber) !== String(meetingNumber)) { + return res.status(400).json({ message: "Meeting number does not match seminar" }); + } + + const status = getSeminarStatus(seminar); + if (status !== "Live Now") { + return res.status(403).json({ message: "Zoom join is only available while this seminar is live" }); + } + + const role = Number(req.query.role ?? 0); + if (![0, 1].includes(role)) { + return res.status(400).json({ message: "Invalid role. Use 0 (attendee) or 1 (host)" }); + } + + const videoWebRtcMode = req.query.videoWebRtcMode === undefined + ? undefined + : Number(req.query.videoWebRtcMode); + + if (videoWebRtcMode !== undefined && ![0, 1].includes(videoWebRtcMode)) { + return res.status(400).json({ message: "Invalid videoWebRtcMode. Use 0 or 1" }); + } + + const iat = Math.floor(Date.now() / 1000); + const exp = iat + 60 * 60 * 2; + + const payload = { + appKey: sdkKey, + sdkKey, + mn: String(meetingNumber), + role, + iat, + exp, + tokenExp: exp + }; + + if (videoWebRtcMode !== undefined) { + payload.video_webrtc_mode = videoWebRtcMode; + } + + const signature = jwt.sign(payload, sdkSecret, { algorithm: "HS256" }); + + res.json({ signature, sdkKey }); + } catch (error) { + console.error("Zoom signature error:", error); + res.status(500).json({ message: "Error generating Zoom signature" }); + } +}); + // Update seminar router.put("/:id", async (req, res) => { try { + const payload = { + ...req.body, + ...(req.body.schedule ? { schedule: normalizeSchedule(req.body.schedule) } : {}) + }; + const updated = await Seminar.findByIdAndUpdate( req.params.id, - req.body, + payload, { new: true, runValidators: true } ); res.json(updated); diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 0000000..276d696 --- /dev/null +++ b/backend/routes/upload.js @@ -0,0 +1,87 @@ +const express = require("express"); +const multer = require("multer"); +const cloudinary = require("../config/cloudinary"); + +const router = express.Router(); + +const hasCloudinaryConfig = Boolean( + process.env.CLOUDINARY_CLOUD_NAME && + process.env.CLOUDINARY_API_KEY && + process.env.CLOUDINARY_API_SECRET +); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 5 * 1024 * 1024, + }, + fileFilter: (_req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + return cb(new Error("Only image files are allowed"), false); + } + return cb(null, true); + }, +}); + +router.post("/image", upload.single("image"), async (req, res) => { + try { + if (!hasCloudinaryConfig) { + return res.status(501).json({ error: "Cloudinary upload is not configured" }); + } + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const result = await new Promise((resolve, reject) => { + const uploadStream = cloudinary.uploader.upload_stream( + { + folder: "unifreelancer/courses", + resource_type: "image", + transformation: [ + { width: 1200, height: 675, crop: "limit" }, + { quality: "auto" }, + { fetch_format: "auto" }, + ], + }, + (error, uploadResult) => { + if (error) { + reject(error); + return; + } + resolve(uploadResult); + } + ); + + uploadStream.end(req.file.buffer); + }); + + res.json({ + success: true, + url: result.secure_url, + publicId: result.public_id, + }); + } catch (error) { + console.error("UPLOAD ROUTE ERROR:", error); + res.status(500).json({ + error: error.message || "Failed to upload image", + }); + } +}); + +router.delete("/image/:publicId", async (req, res) => { + try { + if (!hasCloudinaryConfig) { + return res.status(501).json({ error: "Cloudinary upload is not configured" }); + } + + const publicId = req.params.publicId.replace(/_/g, "/"); + await cloudinary.uploader.destroy(publicId); + res.json({ success: true, message: "Image deleted" }); + } catch (error) { + console.error("Delete error:", error); + res.status(500).json({ error: "Failed to delete image" }); + } +}); + +module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js index 077dad8..ee72ff2 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -24,7 +24,7 @@ router.post("/register", async (req, res) => { username, email, password, - role, + accountType, } = req.body; // Basic required fields check @@ -34,6 +34,10 @@ router.post("/register", async (req, res) => { .json({ message: "Missing required fields for registration" }); } + if (accountType && !["student", "instructor"].includes(accountType)) { + return res.status(400).json({ message: "Invalid account type" }); + } + // Check if email or username already exists const existingUser = await User.findOne({ $or: [{ email }, { username }], @@ -46,8 +50,8 @@ router.post("/register", async (req, res) => { lastName, username, email, - password, // <-- will be hashed by the pre-save hook - role, + password, + accountType: accountType || "student", }); await user.save(); @@ -58,7 +62,7 @@ router.post("/register", async (req, res) => { res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', // Use secure cookies in production - sameSite: 'strict', // Prevent CSRF + sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days }); @@ -105,7 +109,7 @@ router.post("/login", async (req, res) => { res.cookie('token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, }); @@ -267,6 +271,25 @@ router.delete("/tutorials/:tutorialId/complete", protect, async (req, res) => { // ------------------------------- // Enroll in a course // ------------------------------- +router.post("/enroll/:courseId", protect, async (req, res) => { + try { + const user = await User.findById(req.user._id); + if (!user) return res.status(404).json({ message: "User not found" }); + + if (!user.enrolledCourses.includes(req.params.courseId)) { + user.enrolledCourses.push(req.params.courseId); + await user.save(); + } + + res.json({ + message: "Course enrolled successfully", + enrolledCourses: user.enrolledCourses, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + router.post("/:id/enroll-course/:courseId", async (req, res) => { try { const user = await User.findById(req.params.id); diff --git a/backend/server.js b/backend/server.js index 5474604..f517968 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,7 @@ require("dotenv").config(); const express = require("express"); +const path = require("path"); const cors = require("cors"); const cookieParser = require("cookie-parser"); const connectDB = require("./config/db"); @@ -11,7 +12,9 @@ const PORT = process.env.PORT || 5000; // ------------------------------ // CONNECT TO MONGO // ------------------------------ -connectDB().catch((err) => console.error("MongoDB connection error:", err)); +if (process.env.NODE_ENV !== "test") { + connectDB().catch((err) => console.error("MongoDB connection error:", err)); +} // ------------------------------ // STRIPE WEBHOOK (MUST BE BEFORE express.json()) @@ -27,12 +30,18 @@ app.use( // ------------------------------ // NORMAL MIDDLEWARE // ------------------------------ -app.use(cors({ - origin: process.env.FRONTEND_URL || "http://localhost:3000", - credentials: true -})); +if (process.env.NODE_ENV !== "production") { + app.use(cors({ + origin: process.env.FRONTEND_URL || "http://localhost:3000", + credentials: true + })); +} app.use(express.json()); app.use(cookieParser()); +app.use((req, res, next) => { + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + next(); +}); // ------------------------------ // ROUTES @@ -44,6 +53,11 @@ const seminarsRoutes = require("./routes/seminars"); const podcastsRoutes = require("./routes/podcasts"); const userRoutes = require("./routes/users"); const paymentRoutes = require("./routes/payments"); +const courseProgressRoutes = require("./routes/courseProgress"); +const uploadRoutes = require("./routes/upload"); +const instructorRoutes = require("./routes/instructor"); +const assignmentRoutes = require("./routes/assignments"); +const learningRoutes = require("./routes/learningRoutes"); app.use("/api/academy", academyRoutes); app.use("/api/academy/courses", coursesRoutes); @@ -52,6 +66,11 @@ app.use("/api/academy/seminars", seminarsRoutes); app.use("/api/academy/podcasts", podcastsRoutes); app.use("/api/users", userRoutes); app.use("/api/payments", paymentRoutes); +app.use("/api/courses", courseProgressRoutes); +app.use("/api/upload", uploadRoutes); +app.use("/api/instructor", instructorRoutes); +app.use("/api/assignments", assignmentRoutes); +app.use("/api/learning", learningRoutes); // ------------------------------ // HEALTH CHECK @@ -63,6 +82,21 @@ app.get("/api/health", (req, res) => { }); }); +// ------------------------------ +// SERVE FRONTEND IN PRODUCTION +// ------------------------------ +if (process.env.NODE_ENV === "production") { + const frontendBuildPath = path.join(__dirname, "..", "frontend", "build"); + app.use(express.static(frontendBuildPath)); + + app.use((req, res, next) => { + if (req.path.startsWith("/api")) { + return next(); + } + res.sendFile(path.join(frontendBuildPath, "index.html")); + }); +} + // ------------------------------ // ERROR HANDLER // ------------------------------ @@ -79,7 +113,8 @@ let server; if (process.env.NODE_ENV !== "test") { server = app.listen(PORT, () => { console.log(`Backend running on port ${PORT}`); + console.log(`Environment: ${process.env.NODE_ENV || "development"}`); }); } -module.exports = { app, server }; \ No newline at end of file +module.exports = { app, server }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 12b10bc..3e4b341 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.1", + "@zoom/meetingsdk": "^5.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.5.0", @@ -3523,6 +3524,18 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -3616,6 +3629,12 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, "node_modules/@types/q": { "version": "1.5.8", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz", @@ -3634,6 +3653,17 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -3715,6 +3745,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4134,6 +4170,101 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/@zoom/meetingsdk": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@zoom/meetingsdk/-/meetingsdk-5.1.4.tgz", + "integrity": "sha512-76UG03aIHmWFWwiE801EECc//pgJv1rUYk7msf5xlxwokT1Kr3YaEWmZC7ZNiPCkOW2EzeYZyff0K0x3BZAfZg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-redux": "8.1.2", + "redux": "4.2.1", + "redux-thunk": "2.4.2" + }, + "peerDependencies": { + "lodash": "^4.17.23", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-redux": "8.1.2", + "redux": "4.2.1", + "redux-thunk": "2.4.2" + } + }, + "node_modules/@zoom/meetingsdk/node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@zoom/meetingsdk/node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@zoom/meetingsdk/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@zoom/meetingsdk/node_modules/react-redux": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.2.tgz", + "integrity": "sha512-xJKYI189VwfsFc4CJvHqHlDrzyFTY/3vZACbE+rr/zQ34Xx1wQfB4OTOSeOSNrF6BDVe8OOdxIrAnMGXA3ggfw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/use-sync-external-store": "^0.0.3", + "hoist-non-react-statics": "^3.3.2", + "react-is": "^18.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^16.8 || ^17.0 || ^18.0", + "@types/react-dom": "^16.8 || ^17.0 || ^18.0", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0", + "react-native": ">=0.59", + "redux": "^4 || ^5.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -6209,6 +6340,12 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "license": "MIT" }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8632,6 +8769,21 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -10937,9 +11089,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -13755,6 +13907,25 @@ "node": ">=6.0.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/redux-thunk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", + "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", + "license": "MIT", + "peerDependencies": { + "redux": "^4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16257,6 +16428,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6544aa4..f7f2fda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,10 +2,12 @@ "name": "unifreelancer-frontend", "version": "1.0.0", "private": true, + "proxy": "http://localhost:5000", "description": "UniFreelancer Academy Frontend", "dependencies": { "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.1", + "@zoom/meetingsdk": "^5.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.5.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cdf7e74..6b13918 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,10 +6,15 @@ import LearningHub from './pages/Academy/LearningHub/LearningHub'; import CreateContent from './pages/Academy/CreateContent/CreateContent'; import CreateCourse from './pages/Academy/Courses/CreateCourse'; import CourseDetail from './pages/Academy/Courses/CourseDetail'; +import MyCourses from './pages/Academy/Courses/MyCourses'; +import CourseLearning from './pages/Academy/Courses/CourseLearning'; import CreateSeminar from './pages/Academy/Seminars/CreateSeminar'; -import SeminarDetail from './pages/Academy/Seminars/SeminarSingle'; +import SeminarDetails from './pages/Academy/Seminars/SeminarDetails'; +import SeminarZoomPage from './pages/Academy/Seminars/SeminarZoomPage'; import CreateTutorial from './pages/Academy/Tutorials/CreateTutorial'; import TutorialDetail from './pages/Academy/Tutorials/TutorialDetail'; +import InstructorDashboard from './pages/Instructor/InstructorDashboard'; +import GradingInterface from './pages/Instructor/GradingInterface'; import Login from './pages/Auth/Login'; import Signup from './pages/Auth/Signup'; import Profile from './pages/Auth/Profile'; @@ -21,8 +26,7 @@ function App() { useEffect(() => { const fetchUser = async () => { try { - // eslint-disable-next-line no-undef - const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000'; + const apiUrl = process.env.REACT_APP_API_URL || ''; const response = await fetch(`${apiUrl}/api/users/me`, { credentials: 'include', }); @@ -51,19 +55,25 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> } /> } /> + } /> } /> + } /> } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> + } /> + } /> @@ -95,6 +105,11 @@ NavLink.propTypes = { function Header({ user }) { const location = useLocation(); + const isSeminarJoinRoute = /^\/academy\/seminars\/[^/]+\/join$/.test(location.pathname); + + if (isSeminarJoinRoute) { + return null; + } const isActive = (path) => { return location.pathname === path; @@ -119,6 +134,9 @@ function Header({ user }) { UF Social About Us Inbox + {user?.accountType === 'instructor' && ( + Dashboard + )} {user ? (
@@ -146,7 +164,8 @@ Header.propTypes = { lastName: PropTypes.string, username: PropTypes.string, email: PropTypes.string, - _id: PropTypes.string + _id: PropTypes.string, + accountType: PropTypes.string, }) }; diff --git a/frontend/src/components/Courses/CourseCard.css b/frontend/src/components/Courses/CourseCard.css new file mode 100644 index 0000000..6059bb4 --- /dev/null +++ b/frontend/src/components/Courses/CourseCard.css @@ -0,0 +1,149 @@ +.course-card { + background: #ffffff; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + display: flex; + flex-direction: column; + height: 100%; +} + +.course-card:hover { + transform: translateY(-8px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); +} + +.course-card-image { + height: 180px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +.course-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.course-card-placeholder { + font-size: 4rem; + opacity: 0.9; +} + +.course-card-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.course-card-body h3 { + margin: 0 0 0.75rem; + font-size: 1.25rem; + color: #1a1a1a; + font-weight: 600; + line-height: 1.3; + max-height: 2.6em; + overflow: hidden; + text-overflow: ellipsis; +} + +.course-card-description { + font-size: 0.9rem; + color: #666; + line-height: 1.5; + margin-bottom: 1rem; + flex-grow: 1; + max-height: 4.5em; + overflow: hidden; + text-overflow: ellipsis; +} + +.course-card-meta { + display: flex; + gap: 1rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e5e5e5; + flex-wrap: wrap; +} + +.course-meta-item { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: #555; +} + +.meta-icon { + font-size: 1rem; +} + +.course-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.course-price { + font-weight: 700; + font-size: 1.5rem; +} + +.price-free { + color: #10b981; +} + +.price-paid { + color: #3b82f6; +} + +.view-details-btn { + background: #ff6b35; + color: white; + border: none; + padding: 0.65rem 1.25rem; + border-radius: 6px; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.view-details-btn:hover { + background: #ff5722; + transform: translateX(2px); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .course-card-body { + padding: 1.25rem; + } + + .course-card-body h3 { + font-size: 1.1rem; + } + + .course-card-meta { + gap: 0.75rem; + } + + .course-meta-item { + font-size: 0.8rem; + } + + .view-details-btn { + padding: 0.5rem 1rem; + font-size: 0.85rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseCard.jsx b/frontend/src/components/Courses/CourseCard.jsx new file mode 100644 index 0000000..6ffa5ac --- /dev/null +++ b/frontend/src/components/Courses/CourseCard.jsx @@ -0,0 +1,72 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import "./CourseCard.css"; + +const CourseCard = ({ course }) => { + const navigate = useNavigate(); + const priceAmount = course?.pricing?.amount ?? course?.priceAmount ?? 0; + const isFree = Boolean(course?.isFree || course?.isLiteVersion || priceAmount === 0); + + return ( +
navigate(`/academy/courses/${course._id}`)} + > +
+ {course.thumbnail ? ( + {course.title} + ) : ( +
Course
+ )} +
+ +
+

{course.title}

+ +

+ {course.description?.slice(0, 100)} + {course.description?.length > 100 ? "..." : ""} +

+ +
+
+ Time + {course.duration || "Self-paced"} +
+ +
+ Topic + {course.category || "General"} +
+ +
+ Level + {course.difficulty || "Beginner"} +
+
+ +
+
+ {isFree ? ( + Free + ) : ( + ${priceAmount} + )} +
+ + +
+
+
+ ); +}; + +export default CourseCard; diff --git a/frontend/src/components/ImageUpload.css b/frontend/src/components/ImageUpload.css new file mode 100644 index 0000000..4d04a97 --- /dev/null +++ b/frontend/src/components/ImageUpload.css @@ -0,0 +1,138 @@ +.image-upload-container { + margin-bottom: 1.5rem; +} + +.image-upload-label { + display: block; + font-weight: 600; + color: #333; + margin-bottom: 0.75rem; +} + +.upload-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.upload-option { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.upload-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: #4F46E5; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + width: fit-content; +} + +.upload-button:hover:not(:disabled) { + background: #4338ca; +} + +.upload-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.upload-hint { + font-size: 0.875rem; + color: #666; +} + +.upload-divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 0.5rem 0; +} + +.upload-divider::before, +.upload-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.upload-divider span { + color: #999; + font-size: 0.875rem; + font-weight: 600; +} + +.url-input { + width: 100%; + padding: 0.75rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s; +} + +.url-input:focus { + outline: none; + border-color: #4F46E5; +} + +.url-input:disabled { + background: #f9fafb; + cursor: not-allowed; +} + +.image-preview { + margin-top: 1rem; + position: relative; + border-radius: 8px; + overflow: hidden; + border: 2px solid #e5e7eb; + max-width: 400px; +} + +.image-preview img { + width: 100%; + height: auto; + display: block; +} + +.remove-preview { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: rgba(239, 68, 68, 0.9); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + font-size: 0.875rem; +} + +.remove-preview:hover:not(:disabled) { + background: rgba(220, 38, 38, 0.9); +} + +.remove-preview:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .image-preview { + max-width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/components/ImageUpload.jsx b/frontend/src/components/ImageUpload.jsx new file mode 100644 index 0000000..cea1aa0 --- /dev/null +++ b/frontend/src/components/ImageUpload.jsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import './ImageUpload.css'; + +function ImageUpload({ value, onChange, label }) { + const [uploading, setUploading] = useState(false); + const [preview, setPreview] = useState(value || ''); + + const handleFileChange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + alert('Image size must be less than 5MB'); + return; + } + + try { + setUploading(true); + + const reader = new FileReader(); + reader.onloadend = () => { + setPreview(reader.result); + }; + reader.readAsDataURL(file); + + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch('/api/upload/image', { + method: 'POST', + body: formData, + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + + onChange(data.url); + setPreview(data.url); + + } catch (error) { + console.error('Upload error:', error); + alert('Failed to upload image. Please try again.'); + setPreview(''); + } finally { + setUploading(false); + } + }; + + const handleUrlChange = (url) => { + setPreview(url); + onChange(url); + }; + + const handleRemove = () => { + setPreview(''); + onChange(''); + }; + + return ( +
+ + +
+
+ + Max 5MB +
+ +
+ OR +
+ +
+ handleUrlChange(e.target.value)} + placeholder="Paste image URL" + className="url-input" + disabled={uploading} + /> +
+
+ + {preview && ( +
+ Preview + +
+ )} +
+ ); +} + +export default ImageUpload; \ No newline at end of file diff --git a/frontend/src/components/Seminars/ZoomMeeting.jsx b/frontend/src/components/Seminars/ZoomMeeting.jsx new file mode 100644 index 0000000..3d51278 --- /dev/null +++ b/frontend/src/components/Seminars/ZoomMeeting.jsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef } from "react"; +import { ZoomMtg } from "@zoom/meetingsdk"; + +const ZOOM_SDK_VERSION = process.env.REACT_APP_ZOOM_SDK_VERSION || "5.1.2"; +const ZOOM_SDK_LIB = `https://source.zoom.us/${ZOOM_SDK_VERSION}/lib`; + +function ZoomMeeting({ seminarId, meetingNumber, passWord, userFullName }) { + const initializedRef = useRef(false); + const cleanupTimerRef = useRef(null); + + useEffect(() => { + const suppressZoomCancelErrors = (event) => { + const message = event?.message || event?.error?.message || event?.reason?.message || event?.reason; + if (typeof message === "string" && message.includes("Job was cancelled")) { + event.preventDefault(); + if (typeof event.stopImmediatePropagation === "function") { + event.stopImmediatePropagation(); + } + } + }; + + window.addEventListener("error", suppressZoomCancelErrors); + window.addEventListener("unhandledrejection", suppressZoomCancelErrors); + + return () => { + window.removeEventListener("error", suppressZoomCancelErrors); + window.removeEventListener("unhandledrejection", suppressZoomCancelErrors); + }; + }, []); + + useEffect(() => { + if (cleanupTimerRef.current) { + window.clearTimeout(cleanupTimerRef.current); + cleanupTimerRef.current = null; + } + + const zoomRoot = document.getElementById("zmmtg-root"); + if (zoomRoot) { + zoomRoot.style.display = "block"; + } + + const initZoom = async () => { + if (initializedRef.current) return; + initializedRef.current = true; + + try { + ZoomMtg.setZoomJSLib(ZOOM_SDK_LIB, "/av"); + ZoomMtg.preLoadWasm(); + ZoomMtg.prepareWebSDK(); + + const apiBase = process.env.REACT_APP_API_URL || ""; + const videoWebRtcMode = Number(process.env.REACT_APP_ZOOM_VIDEO_WEBRTC_MODE ?? 1); + const params = new URLSearchParams({ + role: "0", + meetingNumber: String(meetingNumber) + }); + + if ([0, 1].includes(videoWebRtcMode)) { + params.set("videoWebRtcMode", String(videoWebRtcMode)); + } + + const signatureRes = await fetch( + `${apiBase}/api/academy/seminars/${seminarId}/zoom-signature?${params.toString()}`, + { credentials: "include" } + ); + + if (!signatureRes.ok) { + const errorData = await signatureRes.json().catch(() => ({})); + throw new Error(errorData.message || "Unable to get Zoom signature"); + } + + const { signature } = await signatureRes.json(); + if (!signature) { + throw new Error("Invalid signature response"); + } + + ZoomMtg.init({ + leaveUrl: `${window.location.origin}/academy/seminars/${seminarId}`, + patchJsMedia: true, + leaveOnPageUnload: true, + debug: false, + success: () => { + ZoomMtg.join({ + signature, + meetingNumber: String(meetingNumber), + passWord: passWord || "", + userName: userFullName || "Guest User", + userEmail: "", + success: () => {}, + error: (error) => { + initializedRef.current = false; + console.error("Zoom join error", error); + } + }); + }, + error: (error) => { + initializedRef.current = false; + console.error("Zoom init error", error); + } + }); + } catch (error) { + initializedRef.current = false; + console.error("Zoom start error", error); + } + }; + + initZoom(); + + return () => { + cleanupTimerRef.current = window.setTimeout(() => { + const root = document.getElementById("zmmtg-root"); + if (root) { + root.style.display = "none"; + } + + try { + ZoomMtg.leaveMeeting({}); + } catch (error) { + // Ignore cleanup errors from zoom sdk. + } + + document.body.style.overflow = "auto"; + initializedRef.current = false; + }, 150); + }; + }, [seminarId, meetingNumber, passWord, userFullName]); + + return ( +
+ ); +} + +export default ZoomMeeting; diff --git a/frontend/src/pages/Academy/Academy.jsx b/frontend/src/pages/Academy/Academy.jsx index 830d544..7bd689f 100644 --- a/frontend/src/pages/Academy/Academy.jsx +++ b/frontend/src/pages/Academy/Academy.jsx @@ -1,4 +1,3 @@ -/* global process */ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { diff --git a/frontend/src/pages/Academy/Courses/AssignmentBuilder.css b/frontend/src/pages/Academy/Courses/AssignmentBuilder.css new file mode 100644 index 0000000..cdaaae4 --- /dev/null +++ b/frontend/src/pages/Academy/Courses/AssignmentBuilder.css @@ -0,0 +1,439 @@ +.assignment-builder { + background: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; +} + +.assignment-builder h4 { + font-size: 1rem; + font-weight: 600; + color: #1a1a1a; + margin: 0 0 1.5rem 0; +} + +/* No Assignment State */ +.no-assignment { + text-align: center; + padding: 2rem; + background: #f8f9fa; + border-radius: 8px; +} + +.no-assignment p { + color: #999; + margin-bottom: 1rem; +} + +.add-assignment-button { + background: #4F46E5; + color: white; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.add-assignment-button:hover { + background: #4338CA; +} + +/* Assignment Preview */ +.assignment-preview { + background: #f8f9fa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1.5rem; +} + +.assignment-preview-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.assignment-preview h4 { + margin: 0 0 0.5rem 0; + color: #1a1a1a; + font-size: 1.1rem; +} + +.assignment-purpose { + color: #666; + font-size: 0.9rem; + margin: 0 0 0.75rem 0; + line-height: 1.5; +} + +.assignment-stats { + display: flex; + gap: 0.5rem; + font-size: 0.875rem; + color: #666; +} + +.assignment-preview-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.edit-button { + background: white; + color: #4F46E5; + border: 1px solid #4F46E5; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; +} + +.edit-button:hover { + background: #4F46E5; + color: white; +} + +/* Assignment Parts Section */ +.assignment-parts-section, +.grading-criteria-section { + margin: 2rem 0; + padding: 1.5rem; + background: white; + border-radius: 8px; +} + +.add-part-form, +.add-criterion-form { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +/* Parts List */ +.parts-list { + margin-top: 1.5rem; +} + +.part-item { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.part-item:last-child { + margin-bottom: 0; +} + +.part-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.part-header strong { + color: #4F46E5; + font-size: 0.95rem; +} + +.part-instructions { + color: #666; + font-size: 0.9rem; + line-height: 1.6; + margin: 0; +} + +/* Grading Criteria */ +.criteria-list { + margin-top: 1.5rem; +} + +.criterion-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1rem; + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + margin-bottom: 0.5rem; +} + +.criterion-item:last-child { + margin-bottom: 0; +} + +.criterion-name { + flex: 1; + color: #333; + font-size: 0.95rem; +} + +.criterion-points { + color: #4F46E5; + font-weight: 600; + margin-left: 1rem; + margin-right: 0.5rem; +} + +.total-points { + margin-top: 1rem; + padding: 1rem; + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 6px; + text-align: right; + color: #0369a1; + font-size: 0.95rem; +} + +.total-points strong { + font-size: 1rem; +} + +/* Assignment Actions */ +.assignment-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e0e0e0; +} + +.cancel-button { + background: white; + color: #666; + border: 1px solid #ddd; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; +} + +.cancel-button:hover { + background: #f8f9fa; + border-color: #ccc; +} + +.save-button { + background: #10b981; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; +} + +.save-button:hover { + background: #059669; +} + +/* Form Groups */ +.assignment-builder .form-group { + margin-bottom: 1rem; +} + +.assignment-builder .form-group label { + display: block; + font-weight: 500; + color: #333; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.assignment-builder .form-group input, +.assignment-builder .form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.95rem; + transition: border-color 0.2s; + font-family: inherit; +} + +.assignment-builder .form-group input:focus, +.assignment-builder .form-group textarea:focus { + outline: none; + border-color: #4F46E5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.assignment-builder .form-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; +} + +/* Add Button */ +.assignment-builder .add-button { + background: #4F46E5; + color: white; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + margin-top: 1rem; + transition: background 0.2s; +} + +.assignment-builder .add-button:hover { + background: #4338CA; +} + +/* Responsive */ +@media (max-width: 768px) { + .assignment-preview-header { + flex-direction: column; + } + + .assignment-preview-actions { + width: 100%; + justify-content: flex-start; + } + + .assignment-builder .form-row { + grid-template-columns: 1fr; + } + + .assignment-parts-section, + .grading-criteria-section { + padding: 1rem; + } + + .assignment-actions { + flex-direction: column-reverse; + } + + .assignment-actions button { + width: 100%; + } +} + +/* Assignment Type Selector */ +.assignment-type-selector { + margin-bottom: 30px; +} + +.type-options { + display: flex; + gap: 15px; + margin-top: 15px; +} + +.type-option { + flex: 1; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 15px; + cursor: pointer; + transition: all 0.2s; +} + +.type-option:hover { + border-color: #4F46E5; + background: #f8f9fa; +} + +.type-option.selected { + border-color: #4F46E5; + background: #eef2ff; +} + +.type-option input[type="radio"] { + margin-right: 10px; +} + +.type-option-content { + display: flex; + flex-direction: column; + gap: 5px; +} + +.type-option-content strong { + color: #333; + font-size: 16px; +} + +.type-option-content span { + color: #666; + font-size: 13px; +} + +/* Question-based styles */ +.assignment-questions-preview { + margin-top: 20px; + padding: 20px; + background: white; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.questions-list { + margin: 15px 0; +} + +.question-preview-item { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + margin-bottom: 10px; + border-left: 4px solid #4F46E5; +} + +.question-preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.question-number { + font-weight: 600; + color: #333; + margin-right: 10px; +} + +.question-type-badge { + background: #4F46E5; + color: white; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + margin-right: 8px; +} + +.question-points-badge { + background: #10b981; + color: white; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.question-preview-text { + margin: 0; + color: #555; + font-size: 14px; +} + +.total-points { + margin-top: 15px; + padding-top: 15px; + border-top: 2px solid #e0e0e0; + font-size: 16px; + color: #333; +} \ No newline at end of file diff --git a/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx b/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx new file mode 100644 index 0000000..31ca04f --- /dev/null +++ b/frontend/src/pages/Academy/Courses/AssignmentBuilder.jsx @@ -0,0 +1,425 @@ +import React, { useState } from 'react'; +import './AssignmentBuilder.css'; +import AssignmentQuestionBuilder from './AssignmentQuestionBuilder'; + +function AssignmentBuilder({ assignment, onSave, onRemove }) { + const [isBuilding, setIsBuilding] = useState(false); + const [assignmentType, setAssignmentType] = useState('question-based'); // 'question-based' or 'part-based' + + // Question-based state + const [assignmentQuestions, setAssignmentQuestions] = useState([]); + + // Part-based state (your original) + const [assignmentData, setAssignmentData] = useState( + assignment || { + title: '', + purpose: '', + instructions: '', + parts: [], + gradingCriteria: [], + deliverableFormat: '', + totalPoints: 30 + } + ); + + const [newPart, setNewPart] = useState({ + partNumber: 1, + title: '', + instructions: '' + }); + + const [newCriterion, setNewCriterion] = useState({ + name: '', + points: 0 + }); + + // Question-based handlers + const handleAddQuestion = (question) => { + const questionWithNumber = { + ...question, + questionNumber: assignmentQuestions.length + 1 + }; + setAssignmentQuestions([...assignmentQuestions, questionWithNumber]); + }; + + const removeQuestion = (index) => { + const updated = assignmentQuestions.filter((_, i) => i !== index); + const renumbered = updated.map((q, i) => ({ ...q, questionNumber: i + 1 })); + setAssignmentQuestions(renumbered); + }; + + // Part-based handlers (your original) + const addPart = () => { + if (!newPart.title || !newPart.instructions) { + alert('Please fill in part title and instructions'); + return; + } + + setAssignmentData({ + ...assignmentData, + parts: [...assignmentData.parts, { ...newPart, partNumber: assignmentData.parts.length + 1 }] + }); + + setNewPart({ partNumber: assignmentData.parts.length + 2, title: '', instructions: '' }); + }; + + const removePart = (index) => { + setAssignmentData({ + ...assignmentData, + parts: assignmentData.parts.filter((_, i) => i !== index) + }); + }; + + const addCriterion = () => { + if (!newCriterion.name || newCriterion.points <= 0) { + alert('Please fill in criterion name and points'); + return; + } + + setAssignmentData({ + ...assignmentData, + gradingCriteria: [...assignmentData.gradingCriteria, newCriterion] + }); + + setNewCriterion({ name: '', points: 0 }); + }; + + const removeCriterion = (index) => { + setAssignmentData({ + ...assignmentData, + gradingCriteria: assignmentData.gradingCriteria.filter((_, i) => i !== index) + }); + }; + + const handleSave = () => { + if (assignmentType === 'question-based') { + // Save question-based assignment + if (assignmentQuestions.length === 0) { + alert('Please add at least one question'); + return; + } + + const totalPoints = assignmentQuestions.reduce((sum, q) => sum + q.points, 0); + + const newAssignment = { + title: 'Module Assignment', + instructions: 'Complete all questions below', + questions: assignmentQuestions, + totalPoints + }; + + onSave(newAssignment); + setIsBuilding(false); + setAssignmentQuestions([]); + + } else { + // Save part-based assignment (your original) + if (!assignmentData.title) { + alert('Please enter an assignment title'); + return; + } + if (!assignmentData.purpose) { + alert('Please enter the assignment purpose'); + return; + } + if (assignmentData.parts.length === 0) { + alert('Please add at least one part'); + return; + } + if (assignmentData.gradingCriteria.length === 0) { + alert('Please add grading criteria'); + return; + } + + onSave(assignmentData); + setIsBuilding(false); + } + }; + + const handleCancel = () => { + setAssignmentData({ + title: '', + purpose: '', + instructions: '', + parts: [], + gradingCriteria: [], + deliverableFormat: '', + totalPoints: 30 + }); + setAssignmentQuestions([]); + setIsBuilding(false); + }; + + // Preview existing assignment + if (assignment && !isBuilding) { + const isQuestionBased = assignment.questions && assignment.questions.length > 0; + + return ( +
+
+
+

{assignment.title || 'Module Assignment'}

+ {assignment.purpose &&

{assignment.purpose}

} +
+ {isQuestionBased ? ( + <> + {assignment.questions.length} questions + + {assignment.totalPoints} points + + ) : ( + <> + {assignment.parts?.length || 0} parts + + {assignment.gradingCriteria?.reduce((sum, c) => sum + c.points, 0) || 0} points + + )} +
+
+
+ + +
+
+
+ ); + } + + if (!isBuilding && !assignment) { + return ( +
+

No assignment added yet

+ +
+ ); + } + + return ( +
+ {/* Assignment Type Selector */} +
+

Assignment Type

+
+ + + +
+
+ + {/* Question-Based Builder */} + {assignmentType === 'question-based' && ( +
+ + + {assignmentQuestions.length > 0 && ( +
+

Assignment Questions ({assignmentQuestions.length})

+
+ {assignmentQuestions.map((q, index) => ( +
+
+
+ Q{index + 1} + {q.type} + {q.points} pts +
+ +
+

{q.question}

+
+ ))} +
+
+ Total Points: + {assignmentQuestions.reduce((sum, q) => sum + q.points, 0)} pts +
+
+ )} +
+ )} + + {/* Part-Based Builder (Your Original) */} + {assignmentType === 'part-based' && ( +
+
+ + setAssignmentData({ ...assignmentData, title: e.target.value })} + placeholder="e.g., Building Your Brand Identity & Social Currency Strategy" + /> +
+ +
+ +