diff --git a/.vscode/settings.json b/.vscode/settings.json index 19f3e17..ce6d5a1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,5 @@ "Namespaces", "Structs" ], - "cSpell.words": ["uuidv"] + "cSpell.words": ["uuidv", "virtuals"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaba21f --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Course Material and FAQ for my NodeJS - Build a Full E-Commerce RESTful APIs (بالعربي) + +This repo contains every course section in a single branch and the finished project files for all the projects contained in the master branch + +Choose the section branch that you study, and **final code to compare it with your own code whenever something doesn't work**! + +## Join To Discord Channel For Updates [discord](https://discord.gg/e2nwBNU2q9) + + +👇 **_Please read the following Frequently Asked Questions (FAQ) carefully before starting the course_** 👇 + +## FAQ + +### Q1: How do I download the files? + +**A:** If you're new to GitHub and just want to download the entire code, hit the green button saying "Code", and then choose the "Download ZIP" option. + + +### Q2: I'm stuck in one of the projects. Where do I get help? + +**A:** Have you actually tried to fix the problem on your own? Have you compared your code to the final code? If you failed fixing your problem, please **post a detailed description of the problem to the Q&A area of that video over at Udemy**, along with a [codepen](https://codepen.io/pen/) containing your code. You will get help there. Please don't send me a personal message or email to fix coding problems. + + +### Q3: I want to put the project in my portfolio. Is that allowed? + +**A:** Absolutely! Just make sure you actually built it yourself by following the course, and that you understand what you did. What is **not allowed** is that you create your own course/videos/articles based on this course's content! + + +### Q4: Do you accept pull requests? + +**A:** No, for the simple reason that I want this repository to contain the _exact_ same code that is shown in the videos. However, please feel free to add an issue if you found one. + + +## Course Highlights + +1- Project Overview + +خلال هذا القسم هيتم استعراض مشروع المتجر الإلكتروني اللي هيتم تنفيذه خلال هذا الكورس ... مهم جدا تتفرج عليه بتركيز عشان تكون عارف ايه المميزات اللي هتتنفذ خلال المشروع ده + +2- How Web Work + +خلال القسم ده هنتكلم شويه عن اساسيات النتورك وازاي الويب بيشتغل عشان كله يكون عنده الاساسيات اللي هنبني عليها اللي جاي وفي نفس الوقت نكون عارف احنا مكانا فين بالظبط وايه دورنا واحنا بنكتب كود + +3- Preparing Tools And Environment + +خلال القسم ده هنبدأ نجهز بيئة العمل بتاعتنا والمحرر اللي هنبدأ نشتغل عليه + +4- Preparing Express Server And Mongodb + +خلال القسم ده هنبدأ نجهز الاكسبريس اب بتاعنا ونبدأ ننشأ السيرفر ونربط التطبيق بتاعنا بالداتا بيز وكمان هنشرح الستراكشر بتاع الملفات اللي هنشتغل بيه خلال المشروع اللي هننفذه + +5- Categories CRUD Operations + +خلال القسم ده هنبدأ التنفيذ الفعل لفيتشر الاقسام داخل المتجر الالكتروني الاقسام دي ممكن تكون ملابس او الكترونيات ..إلى آخره. + +6- Advanced Error Handling & Adding Validation Layer + +من السكاشن المهمة جدا اللي هنشرح فيها ازاي اكسبريس بيتعامل مع الايرورز وهنبدأ نشوف ازاي نمسك الايرورز دي ونتحكم في شكلها والشكل النهائي اللي هيرجع للمستخدم وكمان هنشوف ازاي نمسك باقي الايرورز اللي ممكن تحصل في باقي التطبيق غير اكسبريس + +7- SubCategories CRUD & Brands CRUD Operations + +خلال القسم ده هنبدأ ننفذ الاقسام الفرعية اللي هتكون بتنتمي للاقسام الرئيسية بمعني ان القسم الرئيسي ينتمي ليه قسم او اكثر فرعي .. بالاضافه للعمل علي فيشتر البراندات + +8- Products CRUD Operations + +خلال القسم ده هنبدأ نشتغل علي فيتشر المنتج وهنشوف ازاي نعمل انشاء وتعديل وحذف للمنتج .. بالاضافة ازاي نعمل بحث وازاي نعمل ترتيب للمنتج سواء بسعره او عدد المبيعات للمنتج او غيره .. ازاي كمان نعمل فلتر للمنتج سواء بالقسم اللي بينتمي ليه واو العلامة التجارية وغيره + +9- Upload Single And Multiple Images And Image Processing + +خلال القسم ده هنشوف ازاي نعمل رفع لصوره واحدة او اكتر من صورة .. وهنشوف ازاي نحسن من العمليات اللي هتم علي الصورة عشان يحسن من الاداء .. وهنتعامل مع الايرورز اللي ممكن تظهرك لما ترفع فايل غير الصور .. وهنبدأ نضيف الصور للمنتج بتاعنا + +10- Authentication And Authorization + +خلال القسم ده هنشرح عمليه المصادقة بشكل تفصيلي وهنشوف ازاي تسجيل الدخول وانشاء الحساب ونسيت كلمه المرور وازاي بتعمل التوكن وازاي بنعمل عمليه التحقق عليه ..كمان هنشتغل علي صلاحيات المستخدمين وهيكون عندنا ادمن ومانجر ويوزر عادي وكل واحد ليه صلاحيات مختلفة عن التاني... القسم ده مهم جدا وهتستفاد منه جدا + +11- Reviews, Wishlist And User Addresses + +خلال القسم ده هنبدأ نشتغل علي التقييمات وهنشوف ازاي هنمكن المتسخدم انه يضيف تقييم علي المنتجات وكمان هنحسب متوسط عدد التقييمات علي المنتج الواحد بالاضافة للعدد الكلي للتقيمات علي المنتج الواحد ، كمان هنشرح ازاي نمكن المسخدم انه يضيف منتج لقائمة المفضلة وفي نفس الوقت يقدر يحذفه ، كمان هنمكن المستخدم من انه يضيف عنوان لدفتر العناوين بتاعه يقدر يستخدمه لما يجي يطلب اوردر . + +12- Coupons And Shopping Cart + +خلال القسم ده هنبدأ نمكن الادمن من انه ينشأ الكوبونات وكل كوبون بيكون ليه تاريخ معين ينتهي فيه ونسبة خصم معينة بيحددها الادمن ... والمستخدم هيقدر يستخدم الكوبون ده عشان يتسفاد من الخصم .. كمان هنمكن المستخدم من انه ينشأ سلة المنتجات اللي هيبدأ يضيف فيها المنتجات اللي عايز يشتريها ويعدل يختار ويعدل في كمية المنتجات لو متاح كمية منها في المخزن بالاضافة انه يقدر يضيف كوبون خصم علي السلة . + +13- Cash And Online Orders, Online Payments And Deployments + +خلال القسم ده هنبدأ نشتغل علي الاورد ر او الطلبية سواء الاوردر ده هيتم دفعه كاش او عند الاستلام او الاوردر ده هيتم دفعه من خلال بطاقة دفع او محفظة الكترنية زي ابل باي او غيره .. هيتم الربط مع بوابة الدفع ونشوف ايه وسائل الدفع اللي بتوفرها بوابة الدفع وهنعمل عميلة الدفع من خلالها ... وهنشوف ازاي بنشوف عملية الدفع نجحت ولا لا .. وازاي نعمل اوردر في حالة نجاح عملية الدفع .. هنتكلم بالتفصيل عن الدفع الكاش والدفع الالكتروني .. وفي الاخر هنرفع التطبيق علي هيروكو عشان تقدر تشاركه مع الفرونت اند او تحط اللينك في البرورتفوليو بتاعك + +14- Security + +خلال القسم ده هنتكلم شويه عن وسائل الامان اللي ممكن تستخدمها عشان تأمن التطبيق بتاعك + +15- Enhancements + +خلال القسم ده هنضيف فيه التحسينات اللي هتتضاف في الكورس ... بالاضافة لو فيه مشاكل ظهرت هنسجلها فيديو ونضيفه في السكشن ده + +16- Appendix + +خلال القسم ده هضفلكم شويه دروس عن الجافا سكريبت عشان ترجعو ليها لو عايز تتاسس فيها عشان تساعدك وانت شغال في الكورس + + diff --git a/models/cartModel.js b/models/cartModel.js new file mode 100644 index 0000000..f0b3f35 --- /dev/null +++ b/models/cartModel.js @@ -0,0 +1,29 @@ +const mongoose = require('mongoose'); + +const cartSchema = new mongoose.Schema( + { + cartItems: [ + { + product: { + type: mongoose.Schema.ObjectId, + ref: 'Product', + }, + quantity: { + type: Number, + default: 1, + }, + color: String, + price: Number, + }, + ], + totalCartPrice: Number, + totalPriceAfterDiscount: Number, + user: { + type: mongoose.Schema.ObjectId, + ref: 'User', + }, + }, + { timestamps: true } +); + +module.exports = mongoose.model('Cart', cartSchema); diff --git a/models/couponModel.js b/models/couponModel.js new file mode 100644 index 0000000..024d388 --- /dev/null +++ b/models/couponModel.js @@ -0,0 +1,23 @@ +const mongoose = require('mongoose'); + +const couponSchema = new mongoose.Schema( + { + name: { + type: String, + trim: true, + required: [true, 'Coupon name required'], + unique: true, + }, + expire: { + type: Date, + required: [true, 'Coupon expire time required'], + }, + discount: { + type: Number, + required: [true, 'Coupon discount value required'], + }, + }, + { timestamps: true } +); + +module.exports = mongoose.model('Coupon', couponSchema); diff --git a/models/orderModel.js b/models/orderModel.js new file mode 100644 index 0000000..1dba1f7 --- /dev/null +++ b/models/orderModel.js @@ -0,0 +1,71 @@ +const mongoose = require('mongoose'); + +const orderSchema = new mongoose.Schema( + { + user: { + type: mongoose.Schema.ObjectId, + ref: 'User', + required: [true, 'Order must be belong to user'], + }, + cartItems: [ + { + product: { + type: mongoose.Schema.ObjectId, + ref: 'Product', + }, + quantity: Number, + color: String, + price: Number, + }, + ], + + taxPrice: { + type: Number, + default: 0, + }, + shippingAddress: { + details: String, + phone: String, + city: String, + postalCode: String, + }, + shippingPrice: { + type: Number, + default: 0, + }, + totalOrderPrice: { + type: Number, + }, + paymentMethodType: { + type: String, + enum: ['card', 'cash'], + default: 'cash', + }, + isPaid: { + type: Boolean, + default: false, + }, + paidAt: Date, + isDelivered: { + type: Boolean, + default: false, + }, + deliveredAt: Date, + }, + { timestamps: true } +); + +orderSchema.pre(/^find/, function (next) { + this.populate({ + path: 'user', + select: 'name profileImg email phone', + }).populate({ + path: 'cartItems.product', + select: 'title imageCover ', + }); + + next(); +}); + +module.exports = mongoose.model('Order', orderSchema); + diff --git a/models/productModel.js b/models/productModel.js index f5c29f3..a25cfb4 100644 --- a/models/productModel.js +++ b/models/productModel.js @@ -62,15 +62,27 @@ const productSchema = new mongoose.Schema( type: Number, min: [1, 'Rating must be above or equal 1.0'], max: [5, 'Rating must be below or equal 5.0'], + // set: (val) => Math.round(val * 10) / 10, // 3.3333 * 10 => 33.333 => 33 => 3.3 }, ratingsQuantity: { type: Number, default: 0, }, }, - { timestamps: true } + { + timestamps: true, + // to enable virtual populate + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + } ); +productSchema.virtual('reviews', { + ref: 'Review', + foreignField: 'product', + localField: '_id', +}); + // Mongoose query middleware productSchema.pre(/^find/, function (next) { this.populate({ diff --git a/models/reviewModel.js b/models/reviewModel.js new file mode 100644 index 0000000..51eb359 --- /dev/null +++ b/models/reviewModel.js @@ -0,0 +1,75 @@ +const mongoose = require('mongoose'); +const Product = require('./productModel'); + +const reviewSchema = new mongoose.Schema( + { + title: { + type: String, + }, + ratings: { + type: Number, + min: [1, 'Min ratings value is 1.0'], + max: [5, 'Max ratings value is 5.0'], + required: [true, 'review ratings required'], + }, + user: { + type: mongoose.Schema.ObjectId, + ref: 'User', + required: [true, 'Review must belong to user'], + }, + // parent reference (one to many) + product: { + type: mongoose.Schema.ObjectId, + ref: 'Product', + required: [true, 'Review must belong to product'], + }, + }, + { timestamps: true } +); + +reviewSchema.pre(/^find/, function (next) { + this.populate({ path: 'user', select: 'name' }); + next(); +}); + +reviewSchema.statics.calcAverageRatingsAndQuantity = async function ( + productId +) { + const result = await this.aggregate([ + // Stage 1 : get all reviews in specific product + { + $match: { product: productId }, + }, + // Stage 2: Grouping reviews based on productID and calc avgRatings, ratingsQuantity + { + $group: { + _id: 'product', + avgRatings: { $avg: '$ratings' }, + ratingsQuantity: { $sum: 1 }, + }, + }, + ]); + + // console.log(result); + if (result.length> 0) { + await Product.findByIdAndUpdate(productId, { + ratingsAverage: result[0].avgRatings, + ratingsQuantity: result[0].ratingsQuantity, + }); + } else { + await Product.findByIdAndUpdate(productId, { + ratingsAverage: 0, + ratingsQuantity: 0, + }); + } +}; + +reviewSchema.post('save', async function () { + await this.constructor.calcAverageRatingsAndQuantity(this.product); +}); + +reviewSchema.post('remove', async function () { + await this.constructor.calcAverageRatingsAndQuantity(this.product); +}); + +module.exports = mongoose.model('Review', reviewSchema); diff --git a/models/userModel.js b/models/userModel.js index 3ccb366..96bb500 100644 --- a/models/userModel.js +++ b/models/userModel.js @@ -39,6 +39,23 @@ const userSchema = new mongoose.Schema( type: Boolean, default: true, }, + // child reference (one to many) + wishlist: [ + { + type: mongoose.Schema.ObjectId, + ref: 'Product', + }, + ], + addresses: [ + { + id: { type: mongoose.Schema.Types.ObjectId }, + alias: String, + details: String, + phone: String, + city: String, + postalCode: String, + }, + ], }, { timestamps: true } ); diff --git a/package-lock.json b/package-lock.json index 269ec12..e4b6ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,14 @@ "dependencies": { "bcryptjs": "^2.4.3", "colors": "^1.4.0", + "compression": "^1.7.4", + "cors": "^2.8.5", "dotenv": "^10.0.0", "express": "^4.17.1", "express-async-handler": "^1.2.0", + "express-rate-limit": "^6.3.0", "express-validator": "^6.14.0", + "hpp": "^0.2.3", "jsonwebtoken": "^8.5.1", "mongoose": "^6.0.13", "morgan": "^1.10.0", @@ -22,6 +26,7 @@ "nodemailer": "^6.7.2", "sharp": "^0.29.3", "slugify": "^1.6.3", + "stripe": "^8.212.0", "uuid": "^8.3.2" }, "devDependencies": { @@ -35,6 +40,9 @@ "eslint-plugin-react": "^7.28.0", "nodemon": "^2.0.15", "prettier": "^2.5.1" + }, + "engines": { + "node": "^16.13.0" } }, "node_modules/@babel/runtime": { @@ -840,6 +848,60 @@ "node": ">=0.1.90" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -968,6 +1030,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1873,6 +1947,17 @@ "resolved": "https://registry.npmjs.org/express-async-handler/-/express-async-handler-1.2.0.tgz", "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==" }, + "node_modules/express-rate-limit": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.3.0.tgz", + "integrity": "sha512-932Io1VGKjM3ppi7xW9sb1J5nVkEJSUiOtHw2oE+JyHks1e+AXuOBSXbJKM0mcXwEnW1TibJibQ455Ow1YFjfg==", + "engines": { + "node": ">= 12.9.0" + }, + "peerDependencies": { + "express": "^4" + } + }, "node_modules/express-validator": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", @@ -2326,6 +2411,18 @@ "node": ">=8" } }, + "node_modules/hpp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", + "integrity": "sha512-4zDZypjQcxK/8pfFNR7jaON7zEUpXZxz4viyFmqjb3kWNWAHsLEUmWXcdn25c5l76ISvnD6hbOGO97cXUI3Ryw==", + "dependencies": { + "lodash": "^4.17.12", + "type-is": "^1.6.12" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -4489,6 +4586,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "8.212.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.212.0.tgz", + "integrity": "sha512-xQ2uPMRAmRyOiMZktw3hY8jZ8LFR9lEQRPEaQ5WcDcn51kMyn46GeikOikxiFTHEN8PeKRdwtpz4yNArAvu/Kg==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.6.0" + }, + "engines": { + "node": "^8.1 ||>=10.*" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5582,6 +5691,53 @@ "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5692,6 +5848,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -6389,6 +6554,12 @@ "resolved": "https://registry.npmjs.org/express-async-handler/-/express-async-handler-1.2.0.tgz", "integrity": "sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==" }, + "express-rate-limit": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.3.0.tgz", + "integrity": "sha512-932Io1VGKjM3ppi7xW9sb1J5nVkEJSUiOtHw2oE+JyHks1e+AXuOBSXbJKM0mcXwEnW1TibJibQ455Ow1YFjfg==", + "requires": {} + }, "express-validator": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-6.14.0.tgz", @@ -6730,6 +6901,15 @@ "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", "dev": true }, + "hpp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", + "integrity": "sha512-4zDZypjQcxK/8pfFNR7jaON7zEUpXZxz4viyFmqjb3kWNWAHsLEUmWXcdn25c5l76ISvnD6hbOGO97cXUI3Ryw==", + "requires": { + "lodash": "^4.17.12", + "type-is": "^1.6.12" + } + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -8342,6 +8522,15 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "stripe": { + "version": "8.212.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.212.0.tgz", + "integrity": "sha512-xQ2uPMRAmRyOiMZktw3hY8jZ8LFR9lEQRPEaQ5WcDcn51kMyn46GeikOikxiFTHEN8PeKRdwtpz4yNArAvu/Kg==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.6.0" + } + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 6b59b27..8ff5848 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "start:dev": "nodemon server.js", - "start:prod": "NODE_ENV=production node server.js" + "start": "NODE_ENV=production node server.js" }, "keywords": [], "author": "", @@ -13,10 +13,14 @@ "dependencies": { "bcryptjs": "^2.4.3", "colors": "^1.4.0", + "compression": "^1.7.4", + "cors": "^2.8.5", "dotenv": "^10.0.0", "express": "^4.17.1", "express-async-handler": "^1.2.0", + "express-rate-limit": "^6.3.0", "express-validator": "^6.14.0", + "hpp": "^0.2.3", "jsonwebtoken": "^8.5.1", "mongoose": "^6.0.13", "morgan": "^1.10.0", @@ -24,6 +28,7 @@ "nodemailer": "^6.7.2", "sharp": "^0.29.3", "slugify": "^1.6.3", + "stripe": "^8.212.0", "uuid": "^8.3.2" }, "devDependencies": { @@ -37,5 +42,8 @@ "eslint-plugin-react": "^7.28.0", "nodemon": "^2.0.15", "prettier": "^2.5.1" + }, + "engines": { + "node": "^16.13.0" } } diff --git a/routes/addressRoute.js b/routes/addressRoute.js new file mode 100644 index 0000000..8340b70 --- /dev/null +++ b/routes/addressRoute.js @@ -0,0 +1,19 @@ +const express = require('express'); + +const authService = require('../services/authService'); + +const { + addAddress, + removeAddress, + getLoggedUserAddresses, +} = require('../services/addressService'); + +const router = express.Router(); + +router.use(authService.protect, authService.allowedTo('user')); + +router.route('/').post(addAddress).get(getLoggedUserAddresses); + +router.delete('/:addressId', removeAddress); + +module.exports = router; diff --git a/routes/cartRoute.js b/routes/cartRoute.js new file mode 100644 index 0000000..5adb092 --- /dev/null +++ b/routes/cartRoute.js @@ -0,0 +1,29 @@ +const express = require('express'); + +const { + addProductToCart, + getLoggedUserCart, + removeSpecificCartItem, + clearCart, + updateCartItemQuantity, + applyCoupon, +} = require('../services/cartService'); +const authService = require('../services/authService'); + +const router = express.Router(); + +router.use(authService.protect, authService.allowedTo('user')); +router + .route('/') + .post(addProductToCart) + .get(getLoggedUserCart) + .delete(clearCart); + +router.put('/applyCoupon', applyCoupon); + +router + .route('/:itemId') + .put(updateCartItemQuantity) + .delete(removeSpecificCartItem); + +module.exports = router; diff --git a/routes/categoryRoute.js b/routes/categoryRoute.js index 3172753..5419fd7 100644 --- a/routes/categoryRoute.js +++ b/routes/categoryRoute.js @@ -23,6 +23,7 @@ const subcategoriesRoute = require('./subCategoryRoute'); const router = express.Router(); +// Nested route router.use('/:categoryId/subcategories', subcategoriesRoute); router diff --git a/routes/couponRoute.js b/routes/couponRoute.js new file mode 100644 index 0000000..3686260 --- /dev/null +++ b/routes/couponRoute.js @@ -0,0 +1,20 @@ +const express = require('express'); + +const { + getCoupon, + getCoupons, + createCoupon, + updateCoupon, + deleteCoupon, +} = require('../services/couponService'); + +const authService = require('../services/authService'); + +const router = express.Router(); + +router.use(authService.protect, authService.allowedTo('admin', 'manager')); + +router.route('/').get(getCoupons).post(createCoupon); +router.route('/:id').get(getCoupon).put(updateCoupon).delete(deleteCoupon); + +module.exports = router; diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000..13987ff --- /dev/null +++ b/routes/index.js @@ -0,0 +1,29 @@ +const categoryRoute = require('./categoryRoute'); +const subCategoryRoute = require('./subCategoryRoute'); +const brandRoute = require('./brandRoute'); +const productRoute = require('./productRoute'); +const userRoute = require('./userRoute'); +const authRoute = require('./authRoute'); +const reviewRoute = require('./reviewRoute'); +const wishlistRoute = require('./wishlistRoute'); +const addressRoute = require('./addressRoute'); +const couponRoute = require('./couponRoute'); +const cartRoute = require('./cartRoute'); +const orderRoute = require('./orderRoute'); + +const mountRoutes = (app) => { + app.use('/api/v1/categories', categoryRoute); + app.use('/api/v1/subcategories', subCategoryRoute); + app.use('/api/v1/brands', brandRoute); + app.use('/api/v1/products', productRoute); + app.use('/api/v1/users', userRoute); + app.use('/api/v1/auth', authRoute); + app.use('/api/v1/reviews', reviewRoute); + app.use('/api/v1/wishlist', wishlistRoute); + app.use('/api/v1/addresses', addressRoute); + app.use('/api/v1/coupons', couponRoute); + app.use('/api/v1/cart', cartRoute); + app.use('/api/v1/orders', orderRoute); +}; + +module.exports = mountRoutes; diff --git a/routes/orderRoute.js b/routes/orderRoute.js new file mode 100644 index 0000000..ee81d59 --- /dev/null +++ b/routes/orderRoute.js @@ -0,0 +1,44 @@ +const express = require('express'); +const { + createCashOrder, + findAllOrders, + findSpecificOrder, + filterOrderForLoggedUser, + updateOrderToPaid, + updateOrderToDelivered, + checkoutSession, +} = require('../services/orderService'); + +const authService = require('../services/authService'); + +const router = express.Router(); + +router.use(authService.protect); + +router.get( + '/checkout-session/:cartId', + authService.allowedTo('user'), + checkoutSession +); + +router.route('/:cartId').post(authService.allowedTo('user'), createCashOrder); +router.get( + '/', + authService.allowedTo('user', 'admin', 'manager'), + filterOrderForLoggedUser, + findAllOrders +); +router.get('/:id', findSpecificOrder); + +router.put( + '/:id/pay', + authService.allowedTo('admin', 'manager'), + updateOrderToPaid +); +router.put( + '/:id/deliver', + authService.allowedTo('admin', 'manager'), + updateOrderToDelivered +); + +module.exports = router; diff --git a/routes/productRoute.js b/routes/productRoute.js index e5169ac..e7571eb 100644 --- a/routes/productRoute.js +++ b/routes/productRoute.js @@ -16,9 +16,15 @@ const { resizeProductImages, } = require('../services/productService'); const authService = require('../services/authService'); +const reviewsRoute = require('./reviewRoute'); const router = express.Router(); +// POST /products/jkshjhsdjh2332n/reviews +// GET /products/jkshjhsdjh2332n/reviews +// GET /products/jkshjhsdjh2332n/reviews/87487sfww3 +router.use('/:productId/reviews', reviewsRoute); + router .route('/') .get(getProducts) diff --git a/routes/reviewRoute.js b/routes/reviewRoute.js new file mode 100644 index 0000000..654da75 --- /dev/null +++ b/routes/reviewRoute.js @@ -0,0 +1,50 @@ +const express = require('express'); + +const { + createReviewValidator, + updateReviewValidator, + getReviewValidator, + deleteReviewValidator, +} = require('../utils/validators/reviewValidator'); + +const { + getReview, + getReviews, + createReview, + updateReview, + deleteReview, + createFilterObj, + setProductIdAndUserIdToBody, +} = require('../services/reviewService'); + +const authService = require('../services/authService'); + +const router = express.Router({ mergeParams: true }); + +router + .route('/') + .get(createFilterObj, getReviews) + .post( + authService.protect, + authService.allowedTo('user'), + setProductIdAndUserIdToBody, + createReviewValidator, + createReview + ); +router + .route('/:id') + .get(getReviewValidator, getReview) + .put( + authService.protect, + authService.allowedTo('user'), + updateReviewValidator, + updateReview + ) + .delete( + authService.protect, + authService.allowedTo('user', 'manager', 'admin'), + deleteReviewValidator, + deleteReview + ); + +module.exports = router; diff --git a/routes/wishlistRoute.js b/routes/wishlistRoute.js new file mode 100644 index 0000000..5e6a90f --- /dev/null +++ b/routes/wishlistRoute.js @@ -0,0 +1,19 @@ +const express = require('express'); + +const authService = require('../services/authService'); + +const { + addProductToWishlist, + removeProductFromWishlist, + getLoggedUserWishlist, +} = require('../services/wishlistService'); + +const router = express.Router(); + +router.use(authService.protect, authService.allowedTo('user')); + +router.route('/').post(addProductToWishlist).get(getLoggedUserWishlist); + +router.delete('/:productId', removeProductFromWishlist); + +module.exports = router; diff --git a/server.js b/server.js index b3a6ca2..5647f56 100644 --- a/server.js +++ b/server.js @@ -3,18 +3,18 @@ const path = require('path'); const express = require('express'); const dotenv = require('dotenv'); const morgan = require('morgan'); +const cors = require('cors'); +const compression = require('compression'); +const rateLimit = require('express-rate-limit'); +const hpp = require('hpp'); dotenv.config({ path: 'config.env' }); const ApiError = require('./utils/apiError'); const globalError = require('./middlewares/errorMiddleware'); const dbConnection = require('./config/database'); // Routes -const categoryRoute = require('./routes/categoryRoute'); -const subCategoryRoute = require('./routes/subCategoryRoute'); -const brandRoute = require('./routes/brandRoute'); -const productRoute = require('./routes/productRoute'); -const userRoute = require('./routes/userRoute'); -const authRoute = require('./routes/authRoute'); +const mountRoutes = require('./routes'); +const { webhookCheckout } = require('./services/orderService'); // Connect with db dbConnection(); @@ -22,8 +22,22 @@ dbConnection(); // express app const app = express(); +// Enable other domains to access your application +app.use(cors()); +app.options('*', cors()); + +// compress all responses +app.use(compression()); + +// Checkout webhook +app.post( + '/webhook-checkout', + express.raw({ type: 'application/json' }), + webhookCheckout +); + // Middlewares -app.use(express.json()); +app.use(express.json({ limit: '20kb' })); app.use(express.static(path.join(__dirname, 'uploads'))); if (process.env.NODE_ENV === 'development') { @@ -31,13 +45,32 @@ if (process.env.NODE_ENV === 'development') { console.log(`mode: ${process.env.NODE_ENV}`); } +// Limit each IP to 100 requests per `window` (here, per 15 minutes) +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, + message: + 'Too many accounts created from this IP, please try again after an hour', +}); + +// Apply the rate limiting middleware to all requests +app.use('/api', limiter); + +// Middleware to protect against HTTP Parameter Pollution attacks +app.use( + hpp({ + whitelist: [ + 'price', + 'sold', + 'quantity', + 'ratingsAverage', + 'ratingsQuantity', + ], + }) +); + // Mount Routes -app.use('/api/v1/categories', categoryRoute); -app.use('/api/v1/subcategories', subCategoryRoute); -app.use('/api/v1/brands', brandRoute); -app.use('/api/v1/products', productRoute); -app.use('/api/v1/users', userRoute); -app.use('/api/v1/auth', authRoute); +mountRoutes(app); app.all('*', (req, res, next) => { next(new ApiError(`Can't find this route: ${req.originalUrl}`, 400)); diff --git a/services/addressService.js b/services/addressService.js new file mode 100644 index 0000000..96a0b69 --- /dev/null +++ b/services/addressService.js @@ -0,0 +1,56 @@ +const asyncHandler = require('express-async-handler'); + +const User = require('../models/userModel'); + +// @desc Add address to user addresses list +// @route POST /api/v1/addresses +// @access Protected/User +exports.addAddress = asyncHandler(async (req, res, next) => { + // $addToSet => add address object to user addresses array if address not exist + const user = await User.findByIdAndUpdate( + req.user._id, + { + $addToSet: { addresses: req.body }, + }, + { new: true } + ); + + res.status(200).json({ + status: 'success', + message: 'Address added successfully.', + data: user.addresses, + }); +}); + +// @desc Remove address from user addresses list +// @route DELETE /api/v1/addresses/:addressId +// @access Protected/User +exports.removeAddress = asyncHandler(async (req, res, next) => { + // $pull => remove address object from user addresses array if addressId exist + const user = await User.findByIdAndUpdate( + req.user._id, + { + $pull: { addresses: { _id: req.params.addressId } }, + }, + { new: true } + ); + + res.status(200).json({ + status: 'success', + message: 'Address removed successfully.', + data: user.addresses, + }); +}); + +// @desc Get logged user addresses list +// @route GET /api/v1/addresses +// @access Protected/User +exports.getLoggedUserAddresses = asyncHandler(async (req, res, next) => { + const user = await User.findById(req.user._id).populate('addresses'); + + res.status(200).json({ + status: 'success', + results: user.addresses.length, + data: user.addresses, + }); +}); diff --git a/services/cartService.js b/services/cartService.js new file mode 100644 index 0000000..13da5b9 --- /dev/null +++ b/services/cartService.js @@ -0,0 +1,180 @@ +const asyncHandler = require('express-async-handler'); +const ApiError = require('../utils/apiError'); + +const Product = require('../models/productModel'); +const Coupon = require('../models/couponModel'); +const Cart = require('../models/cartModel'); + +const calcTotalCartPrice = (cart) => { + let totalPrice = 0; + cart.cartItems.forEach((item) => { + totalPrice += item.quantity * item.price; + }); + cart.totalCartPrice = totalPrice; + cart.totalPriceAfterDiscount = undefined; + return totalPrice; +}; + +// @desc Add product to cart +// @route POST /api/v1/cart +// @access Private/User +exports.addProductToCart = asyncHandler(async (req, res, next) => { + const { productId, color } = req.body; + const product = await Product.findById(productId); + + // 1) Get Cart for logged user + let cart = await Cart.findOne({ user: req.user._id }); + + if (!cart) { + // create cart fot logged user with product + cart = await Cart.create({ + user: req.user._id, + cartItems: [{ product: productId, color, price: product.price }], + }); + } else { + // product exist in cart, update product quantity + const productIndex = cart.cartItems.findIndex( + (item) => item.product.toString() === productId && item.color === color + ); + + if (productIndex> -1) { + const cartItem = cart.cartItems[productIndex]; + cartItem.quantity += 1; + + cart.cartItems[productIndex] = cartItem; + } else { + // product not exist in cart, push product to cartItems array + cart.cartItems.push({ product: productId, color, price: product.price }); + } + } + + // Calculate total cart price + calcTotalCartPrice(cart); + await cart.save(); + + res.status(200).json({ + status: 'success', + message: 'Product added to cart successfully', + numOfCartItems: cart.cartItems.length, + data: cart, + }); +}); + +// @desc Get logged user cart +// @route GET /api/v1/cart +// @access Private/User +exports.getLoggedUserCart = asyncHandler(async (req, res, next) => { + const cart = await Cart.findOne({ user: req.user._id }); + + if (!cart) { + return next( + new ApiError(`There is no cart for this user id : ${req.user._id}`, 404) + ); + } + + res.status(200).json({ + status: 'success', + numOfCartItems: cart.cartItems.length, + data: cart, + }); +}); + +// @desc Remove specific cart item +// @route DELETE /api/v1/cart/:itemId +// @access Private/User +exports.removeSpecificCartItem = asyncHandler(async (req, res, next) => { + const cart = await Cart.findOneAndUpdate( + { user: req.user._id }, + { + $pull: { cartItems: { _id: req.params.itemId } }, + }, + { new: true } + ); + + calcTotalCartPrice(cart); + cart.save(); + + res.status(200).json({ + status: 'success', + numOfCartItems: cart.cartItems.length, + data: cart, + }); +}); + +// @desc clear logged user cart +// @route DELETE /api/v1/cart +// @access Private/User +exports.clearCart = asyncHandler(async (req, res, next) => { + await Cart.findOneAndDelete({ user: req.user._id }); + res.status(204).send(); +}); + +// @desc Update specific cart item quantity +// @route PUT /api/v1/cart/:itemId +// @access Private/User +exports.updateCartItemQuantity = asyncHandler(async (req, res, next) => { + const { quantity } = req.body; + + const cart = await Cart.findOne({ user: req.user._id }); + if (!cart) { + return next(new ApiError(`there is no cart for user ${req.user._id}`, 404)); + } + + const itemIndex = cart.cartItems.findIndex( + (item) => item._id.toString() === req.params.itemId + ); + if (itemIndex> -1) { + const cartItem = cart.cartItems[itemIndex]; + cartItem.quantity = quantity; + cart.cartItems[itemIndex] = cartItem; + } else { + return next( + new ApiError(`there is no item for this id :${req.params.itemId}`, 404) + ); + } + + calcTotalCartPrice(cart); + + await cart.save(); + + res.status(200).json({ + status: 'success', + numOfCartItems: cart.cartItems.length, + data: cart, + }); +}); + +// @desc Apply coupon on logged user cart +// @route PUT /api/v1/cart/applyCoupon +// @access Private/User +exports.applyCoupon = asyncHandler(async (req, res, next) => { + // 1) Get coupon based on coupon name + const coupon = await Coupon.findOne({ + name: req.body.coupon, + expire: { $gt: Date.now() }, + }); + + if (!coupon) { + return next(new ApiError(`Coupon is invalid or expired`)); + } + + // 2) Get logged user cart to get total cart price + const cart = await Cart.findOne({ user: req.user._id }); + + const totalPrice = cart.totalCartPrice; + + // 3) Calculate price after priceAfterDiscount + const totalPriceAfterDiscount = ( + totalPrice - + (totalPrice * coupon.discount) / 100 + ).toFixed(2); // 99.23 + + cart.totalPriceAfterDiscount = totalPriceAfterDiscount; + await cart.save(); + + res.status(200).json({ + status: 'success', + numOfCartItems: cart.cartItems.length, + data: cart, + }); +}); diff --git a/services/couponService.js b/services/couponService.js new file mode 100644 index 0000000..badc4fb --- /dev/null +++ b/services/couponService.js @@ -0,0 +1,27 @@ +const factory = require('./handlersFactory'); +const Coupon = require('../models/couponModel'); + +// @desc Get list of coupons +// @route GET /api/v1/coupons +// @access Private/Admin-Manager +exports.getCoupons = factory.getAll(Coupon); + +// @desc Get specific coupon by id +// @route GET /api/v1/coupons/:id +// @access Private/Admin-Manager +exports.getCoupon = factory.getOne(Coupon); + +// @desc Create coupon +// @route POST /api/v1/coupons +// @access Private/Admin-Manager +exports.createCoupon = factory.createOne(Coupon); + +// @desc Update specific coupon +// @route PUT /api/v1/coupons/:id +// @access Private/Admin-Manager +exports.updateCoupon = factory.updateOne(Coupon); + +// @desc Delete specific coupon +// @route DELETE /api/v1/coupons/:id +// @access Private/Admin-Manager +exports.deleteCoupon = factory.deleteOne(Coupon); diff --git a/services/handlersFactory.js b/services/handlersFactory.js index 749718f..e29b07a 100644 --- a/services/handlersFactory.js +++ b/services/handlersFactory.js @@ -10,6 +10,9 @@ exports.deleteOne = (Model) => if (!document) { return next(new ApiError(`No document for this id ${id}`, 404)); } + + // Trigger "remove" event when update document + document.remove(); res.status(204).send(); }); @@ -24,6 +27,8 @@ exports.updateOne = (Model) => new ApiError(`No document for this id ${req.params.id}`, 404) ); } + // Trigger "save" event when update document + document.save(); res.status(200).json({ data: document }); }); @@ -33,10 +38,18 @@ exports.createOne = (Model) => res.status(201).json({ data: newDoc }); }); -exports.getOne = (Model) => +exports.getOne = (Model, populationOpt) => asyncHandler(async (req, res, next) => { const { id } = req.params; - const document = await Model.findById(id); + // 1) Build query + let query = Model.findById(id); + if (populationOpt) { + query = query.populate(populationOpt); + } + + // 2) Execute query + const document = await query; + if (!document) { return next(new ApiError(`No document for this id ${id}`, 404)); } diff --git a/services/orderService.js b/services/orderService.js new file mode 100644 index 0000000..e30dfe7 --- /dev/null +++ b/services/orderService.js @@ -0,0 +1,221 @@ +const stripe = require('stripe')(process.env.STRIPE_SECRET); +const asyncHandler = require('express-async-handler'); +const factory = require('./handlersFactory'); +const ApiError = require('../utils/apiError'); + +const User = require('../models/userModel'); +const Product = require('../models/productModel'); +const Cart = require('../models/cartModel'); +const Order = require('../models/orderModel'); + +// @desc create cash order +// @route POST /api/v1/orders/cartId +// @access Protected/User +exports.createCashOrder = asyncHandler(async (req, res, next) => { + // app settings + const taxPrice = 0; + const shippingPrice = 0; + + // 1) Get cart depend on cartId + const cart = await Cart.findById(req.params.cartId); + if (!cart) { + return next( + new ApiError(`There is no such cart with id ${req.params.cartId}`, 404) + ); + } + + // 2) Get order price depend on cart price "Check if coupon apply" + const cartPrice = cart.totalPriceAfterDiscount + ? cart.totalPriceAfterDiscount + : cart.totalCartPrice; + + const totalOrderPrice = cartPrice + taxPrice + shippingPrice; + + // 3) Create order with default paymentMethodType cash + const order = await Order.create({ + user: req.user._id, + cartItems: cart.cartItems, + shippingAddress: req.body.shippingAddress, + totalOrderPrice, + }); + + // 4) After creating order, decrement product quantity, increment product sold + if (order) { + const bulkOption = cart.cartItems.map((item) => ({ + updateOne: { + filter: { _id: item.product }, + update: { $inc: { quantity: -item.quantity, sold: +item.quantity } }, + }, + })); + await Product.bulkWrite(bulkOption, {}); + + // 5) Clear cart depend on cartId + await Cart.findByIdAndDelete(req.params.cartId); + } + + res.status(201).json({ status: 'success', data: order }); +}); + +exports.filterOrderForLoggedUser = asyncHandler(async (req, res, next) => { + if (req.user.role === 'user') req.filterObj = { user: req.user._id }; + next(); +}); +// @desc Get all orders +// @route POST /api/v1/orders +// @access Protected/User-Admin-Manager +exports.findAllOrders = factory.getAll(Order); + +// @desc Get all orders +// @route POST /api/v1/orders +// @access Protected/User-Admin-Manager +exports.findSpecificOrder = factory.getOne(Order); + +// @desc Update order paid status to paid +// @route PUT /api/v1/orders/:id/pay +// @access Protected/Admin-Manager +exports.updateOrderToPaid = asyncHandler(async (req, res, next) => { + const order = await Order.findById(req.params.id); + if (!order) { + return next( + new ApiError( + `There is no such a order with this id:${req.params.id}`, + 404 + ) + ); + } + + // update order to paid + order.isPaid = true; + order.paidAt = Date.now(); + + const updatedOrder = await order.save(); + + res.status(200).json({ status: 'success', data: updatedOrder }); +}); + +// @desc Update order delivered status +// @route PUT /api/v1/orders/:id/deliver +// @access Protected/Admin-Manager +exports.updateOrderToDelivered = asyncHandler(async (req, res, next) => { + const order = await Order.findById(req.params.id); + if (!order) { + return next( + new ApiError( + `There is no such a order with this id:${req.params.id}`, + 404 + ) + ); + } + + // update order to paid + order.isDelivered = true; + order.deliveredAt = Date.now(); + + const updatedOrder = await order.save(); + + res.status(200).json({ status: 'success', data: updatedOrder }); +}); + +// @desc Get checkout session from stripe and send it as response +// @route GET /api/v1/orders/checkout-session/cartId +// @access Protected/User +exports.checkoutSession = asyncHandler(async (req, res, next) => { + // app settings + const taxPrice = 0; + const shippingPrice = 0; + + // 1) Get cart depend on cartId + const cart = await Cart.findById(req.params.cartId); + if (!cart) { + return next( + new ApiError(`There is no such cart with id ${req.params.cartId}`, 404) + ); + } + + // 2) Get order price depend on cart price "Check if coupon apply" + const cartPrice = cart.totalPriceAfterDiscount + ? cart.totalPriceAfterDiscount + : cart.totalCartPrice; + + const totalOrderPrice = cartPrice + taxPrice + shippingPrice; + + // 3) Create stripe checkout session + const session = await stripe.checkout.sessions.create({ + line_items: [ + { + name: req.user.name, + amount: totalOrderPrice * 100, + currency: 'egp', + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${req.protocol}://${req.get('host')}/orders`, + cancel_url: `${req.protocol}://${req.get('host')}/cart`, + customer_email: req.user.email, + client_reference_id: req.params.cartId, + metadata: req.body.shippingAddress, + }); + + // 4) send session to response + res.status(200).json({ status: 'success', session }); +}); + +const createCardOrder = async (session) => { + const cartId = session.client_reference_id; + const shippingAddress = session.metadata; + const oderPrice = session.amount_total / 100; + + const cart = await Cart.findById(cartId); + const user = await User.findOne({ email: session.customer_email }); + + // 3) Create order with default paymentMethodType card + const order = await Order.create({ + user: user._id, + cartItems: cart.cartItems, + shippingAddress, + totalOrderPrice: oderPrice, + isPaid: true, + paidAt: Date.now(), + paymentMethodType: 'card', + }); + + // 4) After creating order, decrement product quantity, increment product sold + if (order) { + const bulkOption = cart.cartItems.map((item) => ({ + updateOne: { + filter: { _id: item.product }, + update: { $inc: { quantity: -item.quantity, sold: +item.quantity } }, + }, + })); + await Product.bulkWrite(bulkOption, {}); + + // 5) Clear cart depend on cartId + await Cart.findByIdAndDelete(cartId); + } +}; + +// @desc This webhook will run when stripe payment success paid +// @route POST /webhook-checkout +// @access Protected/User +exports.webhookCheckout = asyncHandler(async (req, res, next) => { + const sig = req.headers['stripe-signature']; + + let event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + process.env.STRIPE_WEBHOOK_SECRET + ); + } catch (err) { + return res.status(400).send(`Webhook Error: ${err.message}`); + } + if (event.type === 'checkout.session.completed') { + // Create order + createCardOrder(event.data.object); + } + + res.status(200).json({ received: true }); +}); diff --git a/services/productService.js b/services/productService.js index 725aeb3..db48e80 100644 --- a/services/productService.js +++ b/services/productService.js @@ -62,7 +62,7 @@ exports.getProducts = factory.getAll(Product, 'Products'); // @desc Get specific product by id // @route GET /api/v1/products/:id // @access Public -exports.getProduct = factory.getOne(Product); +exports.getProduct = factory.getOne(Product, 'reviews'); // @desc Create product // @route POST /api/v1/products diff --git a/services/reviewService.js b/services/reviewService.js new file mode 100644 index 0000000..0bfb075 --- /dev/null +++ b/services/reviewService.js @@ -0,0 +1,42 @@ +const factory = require('./handlersFactory'); +const Review = require('../models/reviewModel'); + +// Nested route +// GET /api/v1/products/:productId/reviews +exports.createFilterObj = (req, res, next) => { + let filterObject = {}; + if (req.params.productId) filterObject = { product: req.params.productId }; + req.filterObj = filterObject; + next(); +}; + +// @desc Get list of reviews +// @route GET /api/v1/reviews +// @access Public +exports.getReviews = factory.getAll(Review); + +// @desc Get specific review by id +// @route GET /api/v1/reviews/:id +// @access Public +exports.getReview = factory.getOne(Review); + +// Nested route (Create) +exports.setProductIdAndUserIdToBody = (req, res, next) => { + if (!req.body.product) req.body.product = req.params.productId; + if (!req.body.user) req.body.user = req.user._id; + next(); +}; +// @desc Create review +// @route POST /api/v1/reviews +// @access Private/Protect/User +exports.createReview = factory.createOne(Review); + +// @desc Update specific review +// @route PUT /api/v1/reviews/:id +// @access Private/Protect/User +exports.updateReview = factory.updateOne(Review); + +// @desc Delete specific review +// @route DELETE /api/v1/reviews/:id +// @access Private/Protect/User-Admin-Manager +exports.deleteReview = factory.deleteOne(Review); diff --git a/services/wishlistService.js b/services/wishlistService.js new file mode 100644 index 0000000..562d0e9 --- /dev/null +++ b/services/wishlistService.js @@ -0,0 +1,56 @@ +const asyncHandler = require('express-async-handler'); + +const User = require('../models/userModel'); + +// @desc Add product to wishlist +// @route POST /api/v1/wishlist +// @access Protected/User +exports.addProductToWishlist = asyncHandler(async (req, res, next) => { + // $addToSet => add productId to wishlist array if productId not exist + const user = await User.findByIdAndUpdate( + req.user._id, + { + $addToSet: { wishlist: req.body.productId }, + }, + { new: true } + ); + + res.status(200).json({ + status: 'success', + message: 'Product added successfully to your wishlist.', + data: user.wishlist, + }); +}); + +// @desc Remove product from wishlist +// @route DELETE /api/v1/wishlist/:productId +// @access Protected/User +exports.removeProductFromWishlist = asyncHandler(async (req, res, next) => { + // $pull => remove productId from wishlist array if productId exist + const user = await User.findByIdAndUpdate( + req.user._id, + { + $pull: { wishlist: req.params.productId }, + }, + { new: true } + ); + + res.status(200).json({ + status: 'success', + message: 'Product removed successfully from your wishlist.', + data: user.wishlist, + }); +}); + +// @desc Get logged user wishlist +// @route GET /api/v1/wishlist +// @access Protected/User +exports.getLoggedUserWishlist = asyncHandler(async (req, res, next) => { + const user = await User.findById(req.user._id).populate('wishlist'); + + res.status(200).json({ + status: 'success', + results: user.wishlist.length, + data: user.wishlist, + }); +}); diff --git a/utils/apiFeatures.js b/utils/apiFeatures.js index af51e2e..e2edc59 100644 --- a/utils/apiFeatures.js +++ b/utils/apiFeatures.js @@ -45,7 +45,7 @@ class ApiFeatures { { title: { $regex: this.queryString.keyword, $options: 'i' } }, { description: { $regex: this.queryString.keyword, $options: 'i' } }, ]; - } else { + } else { query = { name: { $regex: this.queryString.keyword, $options: 'i' } }; } diff --git a/utils/validators/reviewValidator.js b/utils/validators/reviewValidator.js new file mode 100644 index 0000000..f434056 --- /dev/null +++ b/utils/validators/reviewValidator.js @@ -0,0 +1,81 @@ +const { check, body } = require('express-validator'); +const validatorMiddleware = require('../../middlewares/validatorMiddleware'); +const Review = require('../../models/reviewModel'); + +exports.createReviewValidator = [ + check('title').optional(), + check('ratings') + .notEmpty() + .withMessage('ratings value required') + .isFloat({ min: 1, max: 5 }) + .withMessage('Ratings value must be between 1 to 5'), + check('user').isMongoId().withMessage('Invalid Review id format'), + check('product') + .isMongoId() + .withMessage('Invalid Review id format') + .custom((val, { req }) => + // Check if logged user create review before + Review.findOne({ user: req.user._id, product: req.body.product }).then( + (review) => { + console.log(review); + if (review) { + return Promise.reject( + new Error('You already created a review before') + ); + } + } + ) + ), + validatorMiddleware, +]; + +exports.getReviewValidator = [ + check('id').isMongoId().withMessage('Invalid Review id format'), + validatorMiddleware, +]; + +exports.updateReviewValidator = [ + check('id') + .isMongoId() + .withMessage('Invalid Review id format') + .custom((val, { req }) => + // Check review ownership before update + Review.findById(val).then((review) => { + if (!review) { + return Promise.reject(new Error(`There is no review with id ${val}`)); + } + + if (review.user._id.toString() !== req.user._id.toString()) { + return Promise.reject( + new Error(`Your are not allowed to perform this action`) + ); + } + }) + ), + validatorMiddleware, +]; + +exports.deleteReviewValidator = [ + check('id') + .isMongoId() + .withMessage('Invalid Review id format') + .custom((val, { req }) => { + // Check review ownership before update + if (req.user.role === 'user') { + return Review.findById(val).then((review) => { + if (!review) { + return Promise.reject( + new Error(`There is no review with id ${val}`) + ); + } + if (review.user._id.toString() !== req.user._id.toString()) { + return Promise.reject( + new Error(`Your are not allowed to perform this action`) + ); + } + }); + } + return true; + }), + validatorMiddleware, +];