diff --git a/_data/bootcamps.json b/_data/bootcamps.json index da806f1..c117ee8 100644 --- a/_data/bootcamps.json +++ b/_data/bootcamps.json @@ -12,8 +12,7 @@ "housing": true, "jobAssistance": true, "jobGuarantee": false, - "acceptGi": true, - "averageCost": 10000 + "acceptGi": true }, { "_id": "5d713a66ec8f2b88b8f830b8", @@ -28,8 +27,7 @@ "housing": false, "jobAssistance": true, "jobGuarantee": false, - "acceptGi": true, - "averageCost": 12000 + "acceptGi": true }, { "_id": "5d725a037b292f5f8ceff787", @@ -44,8 +42,7 @@ "housing": false, "jobAssistance": false, "jobGuarantee": false, - "acceptGi": false, - "averageCost": 8000 + "acceptGi": false }, { "_id": "5d725a1b7b292f5f8ceff788", @@ -65,7 +62,6 @@ "housing": false, "jobAssistance": true, "jobGuarantee": true, - "acceptGi": true, - "averageCost": 5000 + "acceptGi": true } ] diff --git a/controllers/bootcamps.js b/controllers/bootcamps.js index acf7a25..4fe6f27 100644 --- a/controllers/bootcamps.js +++ b/controllers/bootcamps.js @@ -1,9 +1,8 @@ -const geocider = require('../utils/geocoder'); const Bootcamp = require('../models/Bootcamp'); const ErrorResponse = require('../utils/errorResponse'); const asyncHandler = require('../middleware/async'); const geocoder = require('../utils/geocoder'); -const { remove } = require('../models/Bootcamp'); +const path = require('path'); //core node module /** * NOTES @@ -44,7 +43,7 @@ exports.getBootcamps = asyncHandler(async (req, res, next) => { queryStr = queryStr.replace(/\b(gt|gte|lt|lte|in)\b/g, match => `$${match}`); // Finding resource - query = Bootcamp.find(JSON.parse(queryStr)); //queryString is a string, needs to be a JS object + query = Bootcamp.find(JSON.parse(queryStr)).populate('courses'); //queryString is a string, needs to be a JS object // Select Fields ie. ?select=name,description will return only name and description of all entries if (req.query.select) { @@ -157,10 +156,16 @@ exports.updateBootcamp = asyncHandler(async (req, res, next) => { // @route DELETE /api/v1/bootcamps/:id // @access Private exports.deleteBootcamp = asyncHandler(async (req, res, next) => { - const bootcamp = await Bootcamp.findByIdAndDelete(req.params.id); + // const bootcamp = await Bootcamp.findByIdAndDelete(req.params.id); --> "findbyIdAndDelete" will not trigger the delete middleware in Bootcamp.js model + + // INSTEAD, 1. get the bootcamp + const bootcamp = await Bootcamp.findById(req.params.id); if (!bootcamp) return next(new ErrorResponse(`Bootcamp not found with id of ${req.params.id}`, 404)); + // 2. this will trigger middleware + bootcamp.remove(); + res.status(200).json({success: true, data: {}}); }); @@ -189,4 +194,49 @@ exports.getBootcampsInRadius = asyncHandler(async (req, res, next) => { count: bootcamps.length, data: bootcamps }); +}); + +// @desc Upload a photo for bootcamp +// @route PUT /api/v1/bootcamps/:id/photo +// @access Private +exports.bootcampPhotoUpload = asyncHandler(async (req, res, next) => { + + const bootcamp = await Bootcamp.findById(req.params.id); + + if (!bootcamp) return next(new ErrorResponse(`Bootcamp not found with id of ${req.params.id}`, 404)); + + if (!req.files) return next(new ErrorResponse(`Please uoload file`, 400)); + + // console.log(req.files); --> see how file is sent by postman + + const file = req.files.file; + + // Make sure the image is a photo + if (!file.mimetype.startsWith('image')) { + return next(new ErrorResponse(`Please upload an image file`, 400)); + } + + // Check file size + if (file.size > process.env.MAX_FILE_UPLOAD) { + return next(new ErrorResponse(`Please upload an image less than ${process.env.MAX_FILE_UPLOAD}`, 400)); + } + + // Need to change file name (in case someone adds another photo with same name, it won't get overwritten) + file.name = `photo_${bootcamp._id}${path.parse(file.name).ext}`; // get extension of file name before changing it + + //console.log(file.name); + + file.mv(`${process.env.FILE_UPLOAD_PATH}/${file.name}`, async err => { + if (err){ + console.error(err); + return next(new ErrorResponse(`Problem with file upload`, 500)); + } + + await Bootcamp.findByIdAndUpdate(req.params.id, { photo: file.name }); + + res.status(200).json({ + success: true, + data: file.name + }) + }); }); \ No newline at end of file diff --git a/controllers/courses.js b/controllers/courses.js new file mode 100644 index 0000000..6fca544 --- /dev/null +++ b/controllers/courses.js @@ -0,0 +1,115 @@ +const Course = require('../models/Course'); +const Bootcamp = require('../models/Bootcamp'); +const ErrorResponse = require('../utils/errorResponse'); +const asyncHandler = require('../middleware/async'); + +// @desc Get all courses +// @route GET /api/v1/courses +// @route GET /api/v1/bootcamps/:bootcampid/courses +// @access Public + +exports.getCourses = asyncHandler(async (req, res, next) => { + let query; + + if (req.params.bootcampId) { + query = Course.find({ bootcamp: req.params.bootcampId}); + } + else { + query = Course.find().populate({ // populate the course's bootcamp field with the bootcamp name and description + path: 'bootcamp', + select: 'name description' + }); + } + + const courses = await query; + + res.status(200).json({ + success: true, + count: courses.length, + data: courses + }); +}); + +// @desc Get single course by id +// @route GET /api/v1/courses/:id +// @access Public + +exports.getCourse = asyncHandler(async (req, res, next) => { + const course = await Course.findById(req.params.id).populate({ + path: 'bootcamp', + select: 'name description' + }); + + if (!course) { + return next(new ErrorResponse(`No course with id of ${req.params.id}`), 404); + } + + res.status(200).json({ + success: true, + data: course + }); +}); + +// @desc Add course +// @route POST /api/v1/bootcamps/:bootcampid/courses +// @access Private + +exports.addCourse = asyncHandler(async (req, res, next) => { + req.body.bootcamp = req.params.bootcampId; //req.body.bootcamp refers to the 'bootcamp' field in the Course model + + const bootcamp = await Bootcamp.findById(req.params.bootcampId); + + if (!bootcamp) { + return next(new ErrorResponse(`No bootcamp with id of ${req.params.bootcampId}`), 404); + } + + const course = await Course.create(req.body); + + res.status(200).json({ + success: true, + data: course + }); + }); + +// @desc Update a course +// @route PUT /api/v1/courses/:id +// @access Private + +exports.updateCourse = asyncHandler(async (req, res, next) => { + + let course = await Course.findById(req.params.id); + + if (!course) { + return next(new ErrorResponse(`No course with id of ${req.params.id}`), 404); + } + + course = await Course.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true + }); + + res.status(200).json({ + success: true, + data: course + }); + }); + + // @desc Delete a course +// @route DELETE /api/v1/courses/:id +// @access Private + +exports.deleteCourse = asyncHandler(async (req, res, next) => { + + const course = await Course.findById(req.params.id); + + if (!course) { + return next(new ErrorResponse(`No course with id of ${req.params.id}`), 404); + } + + await course.remove(); // cannot use findByIdAndDelete due to course middleware + + res.status(200).json({ + success: true, + data: {} + }); + }); \ No newline at end of file diff --git a/models/Bootcamp.js b/models/Bootcamp.js index 0ae1765..21d924e 100644 --- a/models/Bootcamp.js +++ b/models/Bootcamp.js @@ -99,6 +99,9 @@ const BootcampSchema = new mongoose.Schema({ type: Date, default: Date.now } +}, { + toJSON: { virtuals: true}, + toObject: { virtuals: true } }); /** @@ -139,4 +142,24 @@ BootcampSchema.pre('save', async function(next) { next(); }); +// Cascade delete courses when bootcamp is deleted (if bootcamp is deleted, delete all cooresponding courses) +BootcampSchema.pre('remove', async function (next) { + console.log(`Courses are being removed from bootcamp ${this._id}`); + + // Delete courses with bootcamp field = this._id (current bootcamp) + // Note: this must be "pre" middleware (if it were "post", since we are deleting the bootcamp, we would not be able to access "this" fields) + await this.model('Course').deleteMany({ bootcamp: this._id }); + next(); +}) + +// Reverse Populate with Virtuals (populate Bootcamp object with all courses it coresponds to) + // This creates a new field called 'courses' in Bootcamp model that holds all courses coresponding to bootcamp + // NOTE: this does NOT get saved on the DB +BootcampSchema.virtual('courses', { + ref: 'Course', + localField: '_id', + foreignField: 'bootcamp', // field in the Course model that is tageted + justOne: false // need an array +}) + module.exports = mongoose.model('Bootcamp', BootcampSchema); \ No newline at end of file diff --git a/models/Course.js b/models/Course.js index 47a7a8d..8f7d35d 100644 --- a/models/Course.js +++ b/models/Course.js @@ -1,4 +1,5 @@ const mongoose = require('mongoose'); +const Bootcamp = require('./Bootcamp'); const CourseSchema = new mongoose.Schema({ title: { @@ -38,4 +39,45 @@ const CourseSchema = new mongoose.Schema({ } }); +/** + * MONGOOSE MIDDLEWARE + */ + + // Static method to get avg of course tuitions (static method gets called on Model object) + // NOTE: each bootcamp has an array of courses --> this function calculates the average cost of a particular bootcamp's courses + + CourseSchema.statics.getAverageCost = async function(bootcampId) { + //console.log('Calculating avg cost... '.blue); + + const obj = await this.aggregate([ // aggregate funciton called on entire Course model + { + $match: { bootcamp: bootcampId } + }, + { + $group: { + _id: '$bootcamp', + averageCost: { $avg: '$tuition' } + } + } + ]); // ie. console.log(obj) --> [ { _id: 5d725a1b7b292f5f8ceff788, averageCost: 11250 } ], where _id is id of a bootcamp + + // Save averageCost to DB (at Bootcamp object) + try { + // get Bootcamp field from Course + await this.model('Bootcamp').findByIdAndUpdate(bootcampId, { averageCost: Math.ceil(obj[0].averageCost / 10) * 10 }); // log obj to understand + } catch (err) { + console.error(err); + } + } + +//Call getAverage Cost after save +CourseSchema.post('save', function() { + this.constructor.getAverageCost(this.bootcamp); +}); + +// Call getAverageCost before remove +CourseSchema.pre('remove', function() { + this.constructor.getAverageCost(this.bootcamp); +}); + module.exports = mongoose.model('Course', CourseSchema); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cdf5a7f..e74079c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "colors": "^1.4.0", "dotenv": "^8.2.0", "express": "^4.17.1", + "express-fileupload": "^1.2.1", "mongoose": "^5.11.14", "morgan": "^1.10.0", "node-geocoder": "^3.27.0", @@ -352,6 +353,17 @@ "node": ">=0.6.19" } }, + "node_modules/busboy": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz", + "integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==", + "dependencies": { + "dicer": "0.3.0" + }, + "engines": { + "node": ">=4.5.0" + } + }, "node_modules/bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", @@ -680,6 +692,17 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "node_modules/dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "dependencies": { + "streamsearch": "0.1.2" + }, + "engines": { + "node": ">=4.5.0" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -805,6 +828,17 @@ "node": ">= 0.10.0" } }, + "node_modules/express-fileupload": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.2.1.tgz", + "integrity": "sha512-fWPNAkBj+Azt9Itmcz/Reqdg3LeBfaXptDEev2JM8bCC0yDptglCnlizhf0YZauyU5X/g6v7v4Xxqhg8tmEfEA==", + "dependencies": { + "busboy": "^0.3.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -2225,6 +2259,14 @@ "node": ">=0.10.0" } }, + "node_modules/streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -2819,6 +2861,14 @@ "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz", "integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg==" }, + "busboy": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz", + "integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==", + "requires": { + "dicer": "0.3.0" + } + }, "bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", @@ -3070,6 +3120,14 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "dicer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz", + "integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==", + "requires": { + "streamsearch": "0.1.2" + } + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -3177,6 +3235,14 @@ "vary": "~1.1.2" } }, + "express-fileupload": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.2.1.tgz", + "integrity": "sha512-fWPNAkBj+Azt9Itmcz/Reqdg3LeBfaXptDEev2JM8bCC0yDptglCnlizhf0YZauyU5X/g6v7v4Xxqhg8tmEfEA==", + "requires": { + "busboy": "^0.3.1" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4259,6 +4325,11 @@ "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=" }, + "streamsearch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz", + "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=" + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/package.json b/package.json index e0f778c..216d657 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "colors": "^1.4.0", "dotenv": "^8.2.0", "express": "^4.17.1", + "express-fileupload": "^1.2.1", "mongoose": "^5.11.14", "morgan": "^1.10.0", "node-geocoder": "^3.27.0", diff --git a/public/uploads/photo_5d725a1b7b292f5f8ceff788.jpg b/public/uploads/photo_5d725a1b7b292f5f8ceff788.jpg new file mode 100644 index 0000000..b2cf399 Binary files /dev/null and b/public/uploads/photo_5d725a1b7b292f5f8ceff788.jpg differ diff --git a/routes/bootcamps.js b/routes/bootcamps.js index 93889a2..9eb602c 100644 --- a/routes/bootcamps.js +++ b/routes/bootcamps.js @@ -1,6 +1,16 @@ const express = require('express'); -const router = express.Router(); // allows api routes to be in different file than server.js +// allows api routes to be in different file than server.js +const router = express.Router(); + +//Include other resource routers +const courseRouter = require('./courses'); + +// Re-route to other resource routers + // if this route is hit, will continue on to './courses' which will call the appropriate route + // allows re-routing so that no need to import 'getCourses' route in this file +router.use('/:bootcampId/courses', courseRouter); + // Notes: if APIs were in this file // 1. Replace "app.xxx" with "router.xxx" @@ -13,9 +23,11 @@ const router = express.Router(); // allows api routes to be in different file th // However, API logic will be in a middleware file (see ./controllers/bootcamps.js) //1. bring in API names from '../controller/bootcamps - const { getBootcamps, getBootcamp, createBootcamp, updateBootcamp, deleteBootcamp, getBootcampsInRadius } = require('../controllers/bootcamps') - //2. + const { getBootcamps, getBootcamp, createBootcamp, updateBootcamp, deleteBootcamp, getBootcampsInRadius, bootcampPhotoUpload } = require('../controllers/bootcamps') + + // 2. router.route('/radius/:zipcode/:distance').get(getBootcampsInRadius); + router.route('/:id/photo').put(bootcampPhotoUpload); router.route('/').get(getBootcamps).post(createBootcamp); router.route('/:id').get(getBootcamp).put(updateBootcamp).delete(deleteBootcamp); diff --git a/routes/courses.js b/routes/courses.js new file mode 100644 index 0000000..f0548ab --- /dev/null +++ b/routes/courses.js @@ -0,0 +1,11 @@ +const express = require('express'); + +const { getCourses, getCourse, addCourse, updateCourse, deleteCourse } = require('../controllers/courses'); +const { deleteMany } = require('../models/Course'); + +const router = express.Router({ mergeParams: true }); // mergeParams used for re-routing from bootcamps routes to courses routes + +router.route('/').get(getCourses).post(addCourse); +router.route('/:id').get(getCourse).put(updateCourse).delete(deleteCourse); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index fbd218d..87cb5c2 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,9 @@ const express = require('express'); const dotenv = require('dotenv'); const morgan = require('morgan'); const connectDB = require('./config/db'); +const fileupload = require('express-fileupload'); const colors = require('colors'); +const path = require('path'); // Middlwares const errorHandler = require('./middleware/error'); @@ -50,6 +52,7 @@ connectDB(); // Import route files const bootcamps = require('./routes/bootcamps'); +const courses = require('./routes/courses'); const app = express(); @@ -61,8 +64,15 @@ if (process.env.NODE_ENV === 'development'){ app.use(morgan('dev')); } +// File Uploading +app.use(fileupload()); + +// Set 'public' filder as static +app.use(express.static(path.join(__dirname, 'public'))); + // Mount routers (from bootcamps file) app.use('/api/v1/bootcamps', bootcamps); +app.use('/api/v1/courses', courses); app.use(errorHandler);