From fee85b0677915c69b32e6760c7228dc78b46d971 Mon Sep 17 00:00:00 2001 From: Shubham Waje Date: Thu, 29 Jun 2023 13:16:49 +0530 Subject: [PATCH] In progress: Add Database seeding for auth, todo and ecommerce apis - Add logic to generate random data to populate the database in one go - Add logic to store randomly generated credentials in local json file so developer can use those --- .gitignore | 3 + nodemon.json | 1 + package.json | 1 + public/temp/.gitkeep | 0 src/app.js | 9 + src/controllers/apps/auth/user.controllers.js | 4 +- .../apps/ecommerce/product.controllers.js | 12 +- .../apps/social-media/post.controllers.js | 8 +- .../apps/social-media/profile.controllers.js | 4 +- src/seeds/ecommerce.seeds.js | 212 ++++++++++++++++++ src/seeds/social-media.seeds.js | 0 src/seeds/todo.seeds.js | 25 +++ src/seeds/user.seeds.js | 95 ++++++++ src/utils/helpers.js | 19 +- yarn.lock | 5 + 15 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 nodemon.json create mode 100644 public/temp/.gitkeep create mode 100644 src/seeds/ecommerce.seeds.js create mode 100644 src/seeds/social-media.seeds.js create mode 100644 src/seeds/todo.seeds.js create mode 100644 src/seeds/user.seeds.js diff --git a/.gitignore b/.gitignore index d0ba1fb7..5ea63113 100644 --- a/.gitignore +++ b/.gitignore @@ -137,7 +137,10 @@ logs # ignore the image files uploaded in the public/images folder /public/images/* +/public/temp/* + # except .gitkeep to push empty folder to the git repo !/public/images/.gitkeep +!/public/temp/.gitkeep NOTES.md \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 00000000..82357f77 --- /dev/null +++ b/nodemon.json @@ -0,0 +1 @@ +{ "ignore": ["seed-credentials.json"] } diff --git a/package.json b/package.json index e8e122c9..1f219ca8 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "homepage": "https://github.com/hiteshchoudhary/apihub#readme", "dependencies": { + "@faker-js/faker": "^8.0.2", "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/public/temp/.gitkeep b/public/temp/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/app.js b/src/app.js index e7546197..4c0b6186 100644 --- a/src/app.js +++ b/src/app.js @@ -106,6 +106,9 @@ import redirectRouter from "./routes/kitchen-sink/redirect.routes.js"; import requestinspectionRouter from "./routes/kitchen-sink/requestinspection.routes.js"; import responseinspectionRouter from "./routes/kitchen-sink/responseinspection.routes.js"; import statuscodeRouter from "./routes/kitchen-sink/statuscode.routes.js"; +import { getGeneratedCredentials, seedUsers } from "./seeds/user.seeds.js"; +import { seedTodos } from "./seeds/todo.seeds.js"; +import { seedEcommerce } from "./seeds/ecommerce.seeds.js"; // * API DOCS app.use("/api/v1/docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); @@ -153,6 +156,12 @@ app.use("/api/v1/kitchen-sink/cookies", cookieRouter); app.use("/api/v1/kitchen-sink/redirect", redirectRouter); app.use("/api/v1/kitchen-sink/image", imageRouter); +// * Seeding +app.get("/api/v1/seed/generated-credentials", getGeneratedCredentials); +app.post("/api/v1/seed/users", seedUsers(false)); +app.post("/api/v1/seed/todos", seedTodos); +app.post("/api/v1/seed/ecommerce", seedUsers(true), seedEcommerce); + app.delete("/api/v1/reset-db", async (req, res) => { if (dbInstance) { // Drop the whole DB diff --git a/src/controllers/apps/auth/user.controllers.js b/src/controllers/apps/auth/user.controllers.js index ce14703b..04f1af42 100644 --- a/src/controllers/apps/auth/user.controllers.js +++ b/src/controllers/apps/auth/user.controllers.js @@ -8,7 +8,7 @@ import { asyncHandler } from "../../../utils/asyncHandler.js"; import { getLocalPath, getStaticFilePath, - removeImageFile, + removeLocalFile, } from "../../../utils/helpers.js"; import { emailVerificationMailgenContent, @@ -490,7 +490,7 @@ const updateUserAvatar = asyncHandler(async (req, res) => { ); // remove the old avatar - removeImageFile(user.avatar.localPath); + removeLocalFile(user.avatar.localPath); return res .status(200) diff --git a/src/controllers/apps/ecommerce/product.controllers.js b/src/controllers/apps/ecommerce/product.controllers.js index 85cb7ddd..621d5f88 100644 --- a/src/controllers/apps/ecommerce/product.controllers.js +++ b/src/controllers/apps/ecommerce/product.controllers.js @@ -7,7 +7,7 @@ import { getLocalPath, getMongoosePaginationOptions, getStaticFilePath, - removeImageFile, + removeLocalFile, } from "../../../utils/helpers.js"; import { MAXIMUM_SUB_IMAGE_COUNT } from "../../../constants.js"; import { Category } from "../../../models/apps/ecommerce/category.models.js"; @@ -131,10 +131,10 @@ const updateProduct = asyncHandler(async (req, res) => { // Before throwing an error we need to do some cleanup // remove the newly uploaded sub images by multer as there is not updation happening - subImages?.map((img) => removeImageFile(img.localPath)); + subImages?.map((img) => removeLocalFile(img.localPath)); if (product.mainImage.url !== mainImage.url) { // If use has uploaded new main image remove the newly uploaded main image as there is no updation happening - removeImageFile(mainImage.localPath); + removeLocalFile(mainImage.localPath); } throw new ApiError( 400, @@ -170,7 +170,7 @@ const updateProduct = asyncHandler(async (req, res) => { // Once the product is updated. Do some cleanup if (product.mainImage.url !== mainImage.url) { // If user is uploading new main image remove the previous one because we don't need that anymore - removeImageFile(product.mainImage.localPath); + removeLocalFile(product.mainImage.localPath); } return res @@ -263,7 +263,7 @@ const removeProductSubImage = asyncHandler(async (req, res) => { if (removedSubImage) { // remove the file from file system as well - removeImageFile(removedSubImage.localPath); + removeLocalFile(removedSubImage.localPath); } return res @@ -288,7 +288,7 @@ const deleteProduct = asyncHandler(async (req, res) => { productImages.map((image) => { // remove images associated with the product that is being deleted - removeImageFile(image.localPath); + removeLocalFile(image.localPath); }); return res diff --git a/src/controllers/apps/social-media/post.controllers.js b/src/controllers/apps/social-media/post.controllers.js index 9c831823..a95de7db 100644 --- a/src/controllers/apps/social-media/post.controllers.js +++ b/src/controllers/apps/social-media/post.controllers.js @@ -6,7 +6,7 @@ import { getLocalPath, getMongoosePaginationOptions, getStaticFilePath, - removeImageFile, + removeLocalFile, } from "../../../utils/helpers.js"; import { ApiError } from "../../../utils/ApiError.js"; import { MAXIMUM_SOCIAL_POST_IMAGE_COUNT } from "../../../constants.js"; @@ -215,7 +215,7 @@ const updatePost = asyncHandler(async (req, res) => { // Before throwing an error we need to do some cleanup // remove the newly uploaded images by multer as there is not updation happening - images?.map((img) => removeImageFile(img.localPath)); + images?.map((img) => removeLocalFile(img.localPath)); throw new ApiError( 400, @@ -290,7 +290,7 @@ const removePostImage = asyncHandler(async (req, res) => { if (removedImage) { // remove the file from file system as well - removeImageFile(removedImage.localPath); + removeLocalFile(removedImage.localPath); } const aggregatedPost = await SocialPost.aggregate([ @@ -444,7 +444,7 @@ const deletePost = asyncHandler(async (req, res) => { postImages.map((image) => { // remove images associated with the post that is being deleted - removeImageFile(image.localPath); + removeLocalFile(image.localPath); }); return res diff --git a/src/controllers/apps/social-media/profile.controllers.js b/src/controllers/apps/social-media/profile.controllers.js index 67439680..7d3583b0 100644 --- a/src/controllers/apps/social-media/profile.controllers.js +++ b/src/controllers/apps/social-media/profile.controllers.js @@ -8,7 +8,7 @@ import { asyncHandler } from "../../../utils/asyncHandler.js"; import { getLocalPath, getStaticFilePath, - removeImageFile, + removeLocalFile, } from "../../../utils/helpers.js"; /** @@ -184,7 +184,7 @@ const updateCoverImage = asyncHandler(async (req, res) => { ); // remove the old cover image - removeImageFile(profile.coverImage.localPath); + removeLocalFile(profile.coverImage.localPath); updatedProfile = await getUserSocialProfile(req.user._id, req); diff --git a/src/seeds/ecommerce.seeds.js b/src/seeds/ecommerce.seeds.js new file mode 100644 index 00000000..a21ab595 --- /dev/null +++ b/src/seeds/ecommerce.seeds.js @@ -0,0 +1,212 @@ +import { faker } from "@faker-js/faker"; +import { ApiResponse } from "../utils/ApiResponse.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { Category } from "../models/apps/ecommerce/category.models.js"; +import { User } from "../models/apps/auth/user.models.js"; +import { + AvailableOrderStatuses, + AvailablePaymentProviders, + UserRolesEnum, +} from "../constants.js"; +import { Address } from "../models/apps/ecommerce/address.models.js"; +import { getRandomNumber } from "../utils/helpers.js"; +import { Coupon } from "../models/apps/ecommerce/coupon.models.js"; +import { ApiError } from "../utils/ApiError.js"; +import { Product } from "../models/apps/ecommerce/product.models.js"; +import { EcomOrder } from "../models/apps/ecommerce/order.models.js"; + +const categories = new Array(20).fill("_").map(() => ({ + name: faker.commerce.productAdjective().toLowerCase(), +})); + +const addresses = new Array(100).fill("_").map(() => ({ + addressLine1: faker.location.streetAddress(), + addressLine2: faker.location.street(), + city: faker.location.city(), + country: faker.location.country(), + pincode: faker.location.zipCode("######"), + state: faker.location.state(), +})); + +const coupons = new Array(15).fill("_").map(() => { + const discountValue = faker.number.int({ + max: 1000, + min: 100, + }); + + return { + name: faker.lorem.word({ + length: { + max: 15, + min: 8, + }, + }), + couponCode: + faker.lorem.word({ + length: { + max: 8, + min: 5, + }, + }) + `${discountValue}`, + discountValue: discountValue, + isActive: faker.datatype.boolean(), + minimumCartValue: discountValue + 300, + startDate: faker.date.anytime(), + expiryDate: faker.date.future({ + years: 3, + }), + }; +}); + +const products = new Array(50).fill("_").map(() => { + return { + name: faker.commerce.productName(), + description: faker.commerce.productDescription(), + mainImage: { + url: faker.image.urlLoremFlickr({ + category: "product", + }), + localPath: "", + }, + price: +faker.commerce.price({ dec: 0, min: 200, max: 2000 }), + stock: +faker.commerce.price({ dec: 0, min: 10, max: 200 }), + subImages: new Array(4).fill("_").map(() => ({ + url: faker.image.urlLoremFlickr({ + category: "product", + }), + localPath: "", + })), + }; +}); + +const orders = new Array(15).fill("_").map(() => { + const paymentProvider = + AvailablePaymentProviders[ + getRandomNumber(AvailablePaymentProviders.length) + ]; + return { + status: + AvailableOrderStatuses[getRandomNumber(AvailableOrderStatuses.length)], + paymentProvider: paymentProvider === "UNKNOWN" ? "PAYPAL" : paymentProvider, + paymentId: faker.string.alphanumeric({ + casing: "mixed", + length: 24, + }), + isPaymentDone: true, + }; +}); + +const seedCategories = async (owner) => { + await Category.deleteMany({}); + await Category.insertMany( + categories.map((cat) => ({ ...cat, owner: owner })) + ); +}; + +const seedAddresses = async () => { + const users = await User.find(); + await Address.deleteMany({}); + await Address.insertMany( + addresses.map((add) => ({ + ...add, + owner: users[getRandomNumber(users.length)], + })) + ); +}; + +const seedCoupons = async (owner) => { + await Coupon.deleteMany({}); + await Coupon.insertMany( + coupons.map((coupon) => ({ ...coupon, owner: owner })) + ); +}; + +const seedProducts = async () => { + const users = await User.find(); + const categories = await Category.find(); + + await Product.deleteMany({}); + await Product.insertMany( + products.map((product) => ({ + ...product, + owner: users[getRandomNumber(users.length)], + category: categories[getRandomNumber(categories.length)], + })) + ); +}; + +const seedOrders = async () => { + const customers = await User.find(); + const coupons = await Coupon.find(); + const products = await Product.find(); + const addresses = await Address.find(); + + const orderItems = products + .slice(0, getRandomNumber(products.length - 10)) + .map((prod) => { + const orderItem = { + productId: prod._id, + quantity: +faker.commerce.price({ dec: 0, min: 1, max: 5 }), + }; + const orderPrice = prod.price * orderItem.quantity; + let coupon = coupons[getRandomNumber(coupons.length + 20)] ?? null; + let discountedOrderPrice = orderPrice; + if (coupon && coupon.minimumCartValue <= orderPrice) { + discountedOrderPrice -= coupon.discountValue; + } else { + coupon = null; + } + return { + items: [orderItem], + orderPrice, + discountedOrderPrice, + coupon, + }; + }); + + await EcomOrder.deleteMany({}); + await EcomOrder.insertMany( + orders.map((order) => { + const customer = customers[getRandomNumber(customers.length)]; + const orderData = orderItems[getRandomNumber(orderItems.length)]; + return { + ...order, + customer, + address: + addresses.find( + (add) => add.owner?.toString() === customer?._id.toString() + )?._id ?? addresses[getRandomNumber(addresses.length)], + items: orderData.items, + coupon: orderData.coupon, + orderPrice: orderData.orderPrice, + discountedOrderPrice: orderData.discountedOrderPrice, + }; + }) + ); +}; + +const seedEcommerce = asyncHandler(async (req, res) => { + const owner = await User.findOne({ + role: UserRolesEnum.ADMIN, + }); + + if (!owner) { + throw new ApiError( + 500, + "Something went wrong while seeding the data. Please try again once" + ); + } + + await seedCategories(owner._id); + await seedAddresses(); + await seedCoupons(owner._id); + await seedProducts(); + await seedOrders(); + return res + .status(201) + .json( + new ApiResponse(201, {}, "Database populated for ecommerce successfully") + ); +}); + +export { seedEcommerce }; diff --git a/src/seeds/social-media.seeds.js b/src/seeds/social-media.seeds.js new file mode 100644 index 00000000..e69de29b diff --git a/src/seeds/todo.seeds.js b/src/seeds/todo.seeds.js new file mode 100644 index 00000000..f767cc8e --- /dev/null +++ b/src/seeds/todo.seeds.js @@ -0,0 +1,25 @@ +import { faker } from "@faker-js/faker"; +import { Todo } from "../models/apps/todo/todo.models.js"; +import { ApiResponse } from "../utils/ApiResponse.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +const todos = new Array(10).fill("_").map(() => ({ + title: faker.lorem.sentence({ min: 3, max: 5 }), + description: faker.lorem.paragraph({ + min: 10, + max: 15, + }), + isComplete: faker.datatype.boolean({}), +})); + +const seedTodos = asyncHandler(async (req, res) => { + await Todo.deleteMany({}); + + await Todo.insertMany(todos); + + return res + .status(201) + .json(new ApiResponse(201, {}, "Todos inserted successfully")); +}); + +export { seedTodos }; diff --git a/src/seeds/user.seeds.js b/src/seeds/user.seeds.js new file mode 100644 index 00000000..45f5ebc4 --- /dev/null +++ b/src/seeds/user.seeds.js @@ -0,0 +1,95 @@ +import { faker } from "@faker-js/faker"; +import { AvailableUserRoles } from "../constants.js"; +import { User } from "../models/apps/auth/user.models.js"; +import { ApiResponse } from "../utils/ApiResponse.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { getRandomNumber, removeLocalFile } from "../utils/helpers.js"; +import { SocialProfile } from "../models/apps/social-media/profile.models.js"; +import { EcomProfile } from "../models/apps/ecommerce/profile.models.js"; +import { Cart } from "../models/apps/ecommerce/cart.models.js"; +import fs from "fs"; +import { ApiError } from "../utils/ApiError.js"; + +// TODO: Add comments +// TODO: Do meeting on is this approach good or not +// TODO: Add social media seedings +// TODO: ecommerce order seeding refactor. Now order items are only 1 product but we need multiple products as well in a single order so handle that +// ! TODO: Do we need users seeding? as there is no point generating users if developer is only building Auth system. Can we treat it as a middleware for other api services which require authentication? + +const users = new Array(50).fill("_").map(() => ({ + avatar: { + url: faker.internet.avatar(), + localPath: "", + }, + username: faker.internet.userName(), + email: faker.internet.email(), + password: faker.internet.password(), + isEmailVerified: true, + role: AvailableUserRoles[getRandomNumber(2)], +})); + +/** + * @description Seeding handler for users as well as act as a middleware for api services which needs user to be registered + * @param {boolean} isMiddleware + */ +const seedUsers = (isMiddleware = true) => + asyncHandler(async (req, res, next) => { + await User.deleteMany({}); + await SocialProfile.deleteMany({}); + await EcomProfile.deleteMany({}); + await Cart.deleteMany({}); + // remove cred json if + removeLocalFile("./public/temp/seed-credentials.json"); + + const credentials = []; + const userCreationPromise = users.map(async (user) => { + credentials.push({ + username: user.username.toLowerCase(), + password: user.password, + role: user.role, + }); + await User.create(user); + }); + + await Promise.all(userCreationPromise); + + const json = JSON.stringify(credentials); + + fs.writeFileSync( + "./public/temp/seed-credentials.json", + json, + "utf8", + (err) => { + console.log("Error while writing the credentials", err); + } + ); + + if (isMiddleware) { + // This seeding handler can be used as a middleware for the endpoints which require user accounts to be made first + next(); + } else { + // If the seeding function called as a controller + return res + .status(201) + .json(new ApiResponse(201, {}, "Users inserted successfully")); + } + }); + +const getGeneratedCredentials = asyncHandler(async (req, res) => { + try { + const json = fs.readFileSync("./public/temp/seed-credentials.json", "utf8"); + return res + .status(200) + .json( + new ApiResponse( + 200, + JSON.parse(json), + "Dummy credentials fetched successfully" + ) + ); + } catch (error) { + throw new ApiError(404, "No credentials generated yet"); + } +}); + +export { seedUsers, getGeneratedCredentials }; diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 81ea36b4..43fc6fc4 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -112,13 +112,13 @@ export const getLocalPath = (fileName) => { /** * * @param {string} localPath - * @description Removed the image file from the local file system based on the file name + * @description Removed the local file from the local file system based on the file path */ -export const removeImageFile = (localPath) => { +export const removeLocalFile = (localPath) => { fs.unlink(localPath, (err) => { - if (err) console.log("Error while removing image files: ", err); + if (err) console.log("Error while removing local files: ", err); else { - console.log("Removed image:", localPath); + console.log("Removed local: ", localPath); } }); }; @@ -141,7 +141,7 @@ export const removeUnusedMulterImageFilesOnError = (req) => { if (multerFile) { // If there is file uploaded and there is validation error // We want to remove that file - removeImageFile(multerFile.path); + removeLocalFile(multerFile.path); } if (multerFiles) { @@ -151,7 +151,7 @@ export const removeUnusedMulterImageFilesOnError = (req) => { // We want to remove those files as well filesValueArray.map((fileFields) => { fileFields.map((fileObject) => { - removeImageFile(fileObject.path); + removeLocalFile(fileObject.path); }); }); } @@ -181,3 +181,10 @@ export const getMongoosePaginationOptions = ({ }, }; }; + +/** + * @param {number} max Ceil threshold (exclusive) + */ +export const getRandomNumber = (max) => { + return Math.floor(Math.random() * max); +}; diff --git a/yarn.lock b/yarn.lock index 22356e64..f3a9c8b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@faker-js/faker@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.0.2.tgz#bab698c5d3da9c52744e966e0e3eedb6c8b05c37" + integrity sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A== + "@mapbox/node-pre-gyp@^1.0.10": version "1.0.10" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz#8e6735ccebbb1581e5a7e652244cadc8a844d03c"