diff --git a/config/config.js b/config/config.js index fe17fd4..83a870c 100644 --- a/config/config.js +++ b/config/config.js @@ -1,20 +1,25 @@ -import { DataSource } from 'typeorm'; -import User from '../entities/user.entity.js'; -import Job from '../entities/job.entity.js'; +import dotenv from "dotenv" +import { DataSource } from "typeorm"; +import { Course } from "../entities/course.entity.js" +import { UserCourse } from "../entities/userCourse.entity.js" +import UserEntity from "../entities/user.entity.js"; +import Job from "../entities/job.entity.js"; + +dotenv.config() const AppDataSource = new DataSource({ - type: 'postgres', - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432'), + type: "postgres", + host: process.env.DB_HOST, + port: Number.parseInt(process.env.DB_PORT), username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, synchronize: true, - dropSchema: false, - logging: false, - entities: [User, Job], + logging: true, + entities: [UserEntity, Course, UserCourse, Job], subscribers: [], migrations: [], -}); +}) + +export default AppDataSource; -export default AppDataSource; \ No newline at end of file diff --git a/controllers/course.controller.js b/controllers/course.controller.js index e69de29..7a99488 100644 --- a/controllers/course.controller.js +++ b/controllers/course.controller.js @@ -0,0 +1,126 @@ +import { CourseService } from "../services/courseServices.js" +import courseValidation from "../validation/course.validation.js" + +class CourseController { + async createCourse(req, res) { + try { + const { error, value } = courseValidation.createCourse.validate(req.body) + if (error) { + return res.status(400).json({ message: error.details[0].message }) + } + + const course = await CourseService.createCourse(value) + res.status(201).json(course) + } catch (error) { + if (error.message === "Course already exists") { + res.status(409).json({ message: error.message }) + } else { + res.status(400).json({ message: "Error creating course", error: error.message }) + } + } + } + + async getAllCourses(req, res) { + try { + const courses = await CourseService.getAllCourses() + res.json(courses) + } catch (error) { + res.status(500).json({ message: "Error fetching courses", error: error.message }) + } + } + + async getCourseById(req, res) { + try { + const { error, value } = courseValidation.courseIdParam.validate({ id: req.params.id }) + if (error) { + return res.status(400).json({ message: error.details[0].message }) + } + + const course = await CourseService.getCourseById(value.id) + if (course) { + res.json(course) + } else { + res.status(404).json({ message: "Course not found" }) + } + } catch (error) { + res.status(500).json({ message: "Error fetching course", error: error.message }) + } + } + + async updateCourse(req, res) { + try { + const idValidation = courseValidation.courseIdParam.validate({ id: req.params.id }) + if (idValidation.error) { + return res.status(400).json({ message: idValidation.error.details[0].message }) + } + + const { error, value } = courseValidation.updateCourse.validate(req.body) + if (error) { + return res.status(400).json({ message: error.details[0].message }) + } + + const course = await CourseService.updateCourse(idValidation.value.id, value) + if (course) { + res.json(course) + } else { + res.status(404).json({ message: "Course not found" }) + } + } catch (error) { + if (error.message === "Course already exists") { + res.status(409).json({ message: error.message }) + } else { + res.status(400).json({ message: "Error updating course", error: error.message }) + } + } + } + + async deleteCourse(req, res) { + try { + const { error, value } = courseValidation.courseIdParam.validate({ id: req.params.id }) + if (error) { + return res.status(400).json({ message: error.details[0].message }) + } + + await CourseService.deleteCourse(value.id) + res.status(204).send() + } catch (error) { + if (error.message === "Course not found") { + res.status(404).json({ message: error.message }) + } else { + res.status(500).json({ message: "Error deleting course", error: error.message }) + } + } + } + + async updateCourseVideo(req, res) { + try { + const { error: idError, value: idValue } = courseValidation.courseIdParam.validate({ id: req.params.id }) + if (idError) { + return res.status(400).json({ message: idError.details[0].message }) + } + + if (!req.file) { + return res.status(400).json({ message: "No video file uploaded" }) + } + + const videoUrl = `/uploads/courses/videos/${req.file.filename}` + + const { error, value } = courseValidation.updateCourseVideo.validate({ videoUrl }) + if (error) { + return res.status(400).json({ message: error.details[0].message }) + } + + const updatedCourse = await CourseService.updateCourseVideo(idValue.id, value.videoUrl) + if (updatedCourse) { + res.json(updatedCourse) + } else { + res.status(404).json({ message: "Course not found" }) + } + } catch (error) { + res.status(500).json({ message: "Error updating course video", error: error.message }) + } + } +} + +export default new CourseController() + diff --git a/entities/course.entity.js b/entities/course.entity.js new file mode 100644 index 0000000..286dd59 --- /dev/null +++ b/entities/course.entity.js @@ -0,0 +1,65 @@ +import { EntitySchema } from "typeorm" + +export const Course = new EntitySchema({ + name: "Course", + tableName: "courses", + columns: { + id: { + type: "int", + primary: true, + generated: true, + }, + title: { + type: "varchar", + unique: true, + nullable: false, + }, + description: { + type: "text", + nullable: true, + }, + instructorId: { + type: "int", + nullable: false, + }, + level: { + type: "varchar", + nullable: true, + }, + duration: { + type: "int", + nullable: true, + }, + videoUrl: { + type: "varchar", + nullable: true, + }, + createdAt: { + type: "timestamp", + createDate: true, + }, + }, + relations: { + users: { + type: "many-to-many", + target: "UserEntity", + joinTable: { + name: "user_courses", + joinColumn: { + name: "courseId", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "userId", + referencedColumnName: "id", + }, + }, + }, + userCourses: { + type: "one-to-many", + target: "UserCourse", + inverseSide: "course", + }, + }, +}) + diff --git a/entities/user.entity.js b/entities/user.entity.js index 2b63863..1619ac5 100644 --- a/entities/user.entity.js +++ b/entities/user.entity.js @@ -17,7 +17,6 @@ const UserEntity = new EntitySchema({ type: 'varchar', length: 20, }, - // Generalizes to job seeker/employer/institution name name: { type: 'varchar', length: 100, @@ -26,18 +25,15 @@ const UserEntity = new EntitySchema({ type: 'varchar', unique: true, }, - // Tutor-specific (nullable) title: { type: 'varchar', length: 100, nullable: true, }, - // Used as bio (Job Seeker, Tutor, Institution)/description (Company) bio: { type: 'text', nullable: true, }, - // Tutor-specific (nullable) skills: { type: 'simple-array', nullable: true, @@ -47,6 +43,28 @@ const UserEntity = new EntitySchema({ createDate: true, }, }, + relations: { + courses: { + type: 'many-to-many', + target: 'Course', + joinTable: { + name: 'user_courses', + joinColumn: { + name: 'userId', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'courseId', + referencedColumnName: 'id', + }, + }, + }, + userCourses: { + type: 'one-to-many', + target: 'UserCourse', + inverseSide: 'user', + }, + }, indices: [ { columns: ['walletAddress'] }, { columns: ['email'] }, @@ -54,4 +72,4 @@ const UserEntity = new EntitySchema({ ], }); -export default UserEntity; \ No newline at end of file +export default UserEntity diff --git a/entities/userCourse.entity.js b/entities/userCourse.entity.js new file mode 100644 index 0000000..4ff8334 --- /dev/null +++ b/entities/userCourse.entity.js @@ -0,0 +1,45 @@ +import { EntitySchema } from "typeorm" + +export const UserCourse = new EntitySchema({ + name: "UserCourse", + tableName: "user_courses", + columns: { + userId: { + primary: true, + type: "int", + }, + courseId: { + primary: true, + type: "int", + }, + progress: { + type: "int", + default: 0, + }, + enrollmentDate: { + type: "timestamp", + createDate: true, + }, + }, + relations: { + user: { + type: "many-to-one", + target: "User", + joinColumn: { + name: "userId", + referencedColumnName: "id", + }, + onDelete: "CASCADE", + }, + course: { + type: "many-to-one", + target: "Course", + joinColumn: { + name: "courseId", + referencedColumnName: "id", + }, + onDelete: "CASCADE", + }, + }, +}) + diff --git a/http/courseRoute.http b/http/courseRoute.http new file mode 100644 index 0000000..02ae044 --- /dev/null +++ b/http/courseRoute.http @@ -0,0 +1,46 @@ +@baseUrl = http://localhost:3000/api/courses + +### Create a new course +POST {{baseUrl}} +Content-Type: application/json + +{ + "title": "Advance Blockchain", + "description": "Learn the basics of blockchain technology", + "instructorId": 1, + "level": "beginner", + "duration": 5 +} + +### Get all courses +GET {{baseUrl}} + +### Get a specific course +GET {{baseUrl}}/1 + +### Update a course +PUT {{baseUrl}}/1 +Content-Type: application/json +{ + "title": "Advanced Blockchain Concepts", + "description": "Dive deeper into blockchain technology and its applications" +} + +### Delete a course +DELETE {{baseUrl}}/4 + +### Create another course +POST {{baseUrl}} +Content-Type: application/json + +{ + "title": "Advance Smart Contract Development", + "description": "Learn how to create and deploy smart contracts on blockchain platforms", + "instructorId": 2, + "level": "beginner", + "duration": 5 +} + +### Get all courses after operations +GET {{baseUrl}} + diff --git a/middlewares/auth.middleware.js b/middlewares/auth.middleware.js index e69de29..00440c1 100644 --- a/middlewares/auth.middleware.js +++ b/middlewares/auth.middleware.js @@ -0,0 +1,19 @@ +import jwt from "jsonwebtoken" + +const authenticateToken = (req, res, next) => { + // For now, we'll set a mock user object for all requests + req.user = { + id: 1, + username: "mockuser", + role: "instructor", + } + next() +} + +const isInstructor = (req, res, next) => { + // For now, we'll assume all users are instructors + next() +} + +export { authenticateToken, isInstructor } + diff --git a/package-lock.json b/package-lock.json index 5739349..09307e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2102,7 +2102,8 @@ "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -2798,6 +2799,7 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -3258,6 +3260,7 @@ "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4840,6 +4843,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", diff --git a/routes/courseRoutes.js b/routes/courseRoutes.js index e69de29..2f8446e 100644 --- a/routes/courseRoutes.js +++ b/routes/courseRoutes.js @@ -0,0 +1,45 @@ +import express from "express" +import multer from "multer" +import { authenticateToken, isInstructor } from "../middlewares/auth.middleware.js" +import CourseController from "../controllers/course.controller.js" + +const router = express.Router() +const upload = multer() + +router.post( + "/", + authenticateToken, + isInstructor, + upload.fields([ + { name: "courseMaterial", maxCount: 1 }, + { name: "courseVideo", maxCount: 1 }, + ]), + CourseController.createCourse, +) + +router.get("/", CourseController.getAllCourses) +router.get("/:id", CourseController.getCourseById) + +router.put( + "/:id", + authenticateToken, + isInstructor, + upload.fields([ + { name: "courseMaterial", maxCount: 1 }, + { name: "courseVideo", maxCount: 1 }, + ]), + CourseController.updateCourse, +) + +router.delete("/:id", authenticateToken, isInstructor, CourseController.deleteCourse) + +router.put( + "/:id/video", + authenticateToken, + isInstructor, + upload.single("courseVideo"), + CourseController.updateCourseVideo, +) + +export default router + diff --git a/server.js b/server.js index 1f0fb6e..8e009a3 100644 --- a/server.js +++ b/server.js @@ -1,8 +1,9 @@ -import 'dotenv/config' import AppDataSource from "./config/config.js"; +import 'dotenv/config' import express, { json } from 'express'; import authRouter from './routes/auth.routes.js'; import jobRoutes from './routes/jobRoutes.js'; +import courseRoutes from "./routes/courseRoutes.js" import helmet from 'helmet'; import cors from 'cors'; @@ -10,20 +11,21 @@ import cors from 'cors'; const PORT = process.env.PORT || 5000; AppDataSource.initialize() - .then(() => { - console.log('Database connected'); - - const app = express(); - - // Middleware - app.use(helmet()); - app.use(cors()); - app.use(json()); +.then(() => { + console.log('Database connected'); + + const app = express(); + + // Middleware + app.use(helmet()); + app.use(cors()); + app.use(json()); // Routes app.use('/api/auth', authRouter); app.use('/api/jobs', jobRoutes); - + app.use("/api/courses", courseRoutes) + // Error handling app.use((err, req, res, next) => { console.error(err.stack); diff --git a/services/courseServices.js b/services/courseServices.js index e69de29..0059520 100644 --- a/services/courseServices.js +++ b/services/courseServices.js @@ -0,0 +1,120 @@ +import AppDataSource from "../config/config.js" +import { Course } from "../entities/course.entity.js" + +const courseRepository = AppDataSource.getRepository(Course) + +class CourseError extends Error { + constructor(message, statusCode = 500) { + super(message) + this.name = "CourseError" + this.statusCode = statusCode + } +} + +export const CourseService = { + async createCourse(courseData) { + try { + // Check if a course with the same title already exists + const existingCourse = await courseRepository.findOne({ where: { title: courseData.title } }) + if (existingCourse) { + throw new CourseError("A course with this title already exists", 409) // 409 Conflict + } + + const course = courseRepository.create(courseData) + return await courseRepository.save(course) + } catch (error) { + if (error instanceof CourseError) { + throw error + } + throw new CourseError("Failed to create course", 400) + } + }, + + async getAllCourses() { + try { + return await courseRepository.find() + } catch (error) { + throw new CourseError("Failed to fetch courses") + } + }, + + async getCourseById(id) { + try { + const course = await courseRepository.findOneBy({ id }) + if (!course) { + throw new CourseError("Course not found", 404) + } + return course + } catch (error) { + if (error instanceof CourseError) { + throw error + } + throw new CourseError("Failed to fetch course") + } + }, + + async updateCourse(id, courseData) { + try { + // Check if the updated title already exists for another course + if (courseData.title) { + const existingCourse = await courseRepository.findOne({ + where: { title: courseData.title, id: { $ne: id } }, + }) + if (existingCourse) { + throw new CourseError("A course with this title already exists", 409) + } + } + + const updateResult = await courseRepository.update(id, courseData) + if (updateResult.affected === 0) { + throw new CourseError("Course not found", 404) + } + const updatedCourse = await courseRepository.findOneBy({ id }) + if (!updatedCourse) { + throw new CourseError("Failed to fetch updated course") + } + return updatedCourse + } catch (error) { + if (error instanceof CourseError) { + throw error + } + throw new CourseError("Failed to update course") + } + }, + + async deleteCourse(id) { + try { + const deleteResult = await courseRepository.delete(id) + if (deleteResult.affected === 0) { + throw new CourseError("Course not found", 404) + } + } catch (error) { + if (error instanceof CourseError) { + throw error + } + throw new CourseError("Failed to delete course") + } + }, + + async updateCourseVideo(id, videoUrl) { + try { + const updateResult = await courseRepository.update(id, { videoUrl }) + if (updateResult.affected === 0) { + throw new CourseError("Course not found", 404) + } + const updatedCourse = await courseRepository.findOneBy({ id }) + if (!updatedCourse) { + throw new CourseError("Failed to fetch updated course") + } + return updatedCourse + } catch (error) { + if (error instanceof CourseError) { + throw error + } + throw new CourseError("Failed to update course video") + } + }, +} + +// export default CourseService + diff --git a/validation/course.validation.js b/validation/course.validation.js new file mode 100644 index 0000000..3f6d1ce --- /dev/null +++ b/validation/course.validation.js @@ -0,0 +1,37 @@ +import Joi from "joi" + +const courseValidation = { + createCourse: Joi.object({ + title: Joi.string().required().min(3).max(100), + description: Joi.string().required().min(10).max(1000), + instructorId: Joi.number().integer().positive().required(), + duration: Joi.number().integer().positive().required(), + level: Joi.string().valid("beginner", "intermediate", "advanced").required(), + tags: Joi.array().items(Joi.string()).min(1).max(5), + price: Joi.number().precision(2).min(0), + isPublished: Joi.boolean().default(false), + videoUrl: Joi.string().uri().allow(null, ""), // Allow null or empty string for initial creation without video + }), + + updateCourse: Joi.object({ + title: Joi.string().min(3).max(100), + description: Joi.string().min(10).max(1000), + duration: Joi.number().integer().positive(), + level: Joi.string().valid("beginner", "intermediate", "advanced"), + tags: Joi.array().items(Joi.string()).min(1).max(5), + price: Joi.number().precision(2).min(0), + isPublished: Joi.boolean(), + videoUrl: Joi.string().uri().allow(null, ""), + }).min(1), // At least one field must be present for update + + updateCourseVideo: Joi.object({ + videoUrl: Joi.string().uri().required(), + }), + + courseIdParam: Joi.object({ + id: Joi.number().integer().positive().required(), + }), +} + +export default courseValidation +