From 0fee7efad457a3e500803c3c30e6e9ab1c521d49 Mon Sep 17 00:00:00 2001 From: Icyeza Date: Mon, 6 May 2024 03:28:28 +0200 Subject: [PATCH] SQUASH COMMITS Implement user profile update feature --amend feat(product managment) add product managment this commit adds product entity with its relation of a vendor. it is used to implement product managment for vendor. Resolves: #48 feat(product managment) add product managment this commit adds product entity with its relation of a vendor. it is used to implement product managment for vendor. Resolves: #48 adding recommended products route feat(product managment) add product managment this commit adds product entity with its relation of a vendor. it is used to implement product managment for vendor. Resolves: #48 ft enable vendor to update product availability status removing console and sensitive data in reponse Ft-implment-wish list-functionalites Implementation of view a single product function Implement buyer able to list all products google authentication Commits squashing into one commit product search feature feat(cart managment) add cart managment this commit adds cart entity, cart item entity with its relation of a guest/buyer user and product. it is used to implement cart managment for guest/buyer. Resolves: #88 ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management increase test coverage increasing test coverage implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests test coverage ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system test coverage feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products test coverage test coverage This PR is for the addition of the chatbot test coverage test coverage adding test cases for maximizing coverage rebasing with develop product search feature feat(cart managment) add cart managment this commit adds cart entity, cart item entity with its relation of a guest/buyer user and product. it is used to implement cart managment for guest/buyer. Resolves: #88 ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management increase test coverage increasing test coverage implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests test coverage ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system test coverage feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests feat(docker): implement Docker for project - Include Docker file to build image for project and containerize the project - Configure Docker compose for easy setup Implement buyer able to leave feedback on products ft adding discount coupon feature implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests Feat-Buyer-coupon-discount-management implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue implementing order tracking and order managment issue adding documentation and tests writing tests implementing stripe payment system implementing stripe payment system Implement buyer able to leave feedback on products Implement buyer able to leave feedback on products test coverage test coverage test coverage test coverage adding test cases for maximizing coverage --- .dockerignore | 5 + .env.example | 7 +- .github/workflows/ci.yml | 19 +- .gitignore | 12 +- Dockerfile | 13 + Intents/cancel.json | 14 + Intents/confirm.json | 14 + Intents/delay.json | 16 + Intents/delivery.json | 15 + Intents/greetings.bye.json | 18 + Intents/greetings.hello.json | 19 + Intents/items.json | 23 + Intents/more.contact.json | 12 + Intents/more.help.json | 19 + Intents/order.json | 12 + Intents/payments.json | 15 + Intents/personal.json | 12 + Intents/profile.info.json | 15 + Intents/reason.json | 13 + Intents/refund.demanding.json | 14 + Intents/refund.status.json | 14 + Intents/thanks.json | 13 + Intents/track.json | 13 + Intents/user.response.json | 14 + Intents/voucher.json | 12 + README.md | 20 +- "\\" | 55 + docker-compose.yml | 27 + jest.config.ts | 3 +- model.nlp | 4388 +++++++++++++++++ package.json | 15 + src/@types/index.d.ts | 1 + src/__test__/auth.test.ts | 154 + src/__test__/cart.test.ts | 858 ++++ src/__test__/chatBot.test.ts | 53 + src/__test__/coupon.test.ts | 571 +++ src/__test__/errorHandler.test.ts | 47 + src/__test__/getProduct.test.ts | 158 + src/__test__/index.test.ts | 107 + src/__test__/index.utils.test.ts | 35 + src/__test__/isAllowed.test.ts | 138 +- src/__test__/logger.test.ts | 80 + src/__test__/logout.test.ts | 9 +- src/__test__/notification.test.ts | 231 + src/__test__/oauth.test.ts | 24 + src/__test__/orderManagement.test.ts | 390 ++ src/__test__/product.entities.test.ts | 175 + src/__test__/productStatus.test.ts | 237 + src/__test__/roleCheck.test.ts | 112 +- src/__test__/route.test.ts | 66 +- src/__test__/searchProduct.test.ts | 163 + src/__test__/test-assets/DatabaseCleanup.ts | 52 + src/__test__/test-assets/photo1.png | Bin 0 -> 42194 bytes src/__test__/test-assets/photo2.webp | Bin 0 -> 27306 bytes src/__test__/user.entity.test.ts | 277 ++ src/__test__/userServices.test.ts | 18 +- src/__test__/userStatus.test.ts | 12 +- src/__test__/vendorProduct.test.ts | 473 ++ src/__test__/wishList.test.ts | 204 + src/controllers/adminOrdercontroller.ts | 18 + src/controllers/authController.ts | 8 +- src/controllers/cartController.ts | 18 + src/controllers/chatBotController.ts | 6 + src/controllers/couponController.ts | 31 + src/controllers/feedbackController.ts | 21 + src/controllers/index.ts | 7 +- src/controllers/notificationControllers.ts | 22 + src/controllers/orderController.ts | 22 + src/controllers/productController.ts | 76 + src/controllers/vendorOrderController.ts | 14 + src/controllers/wishListController.ts | 18 + src/docs/adminOrderManagement.yml | 80 + src/docs/authDocs.yml | 2 +- src/docs/cartDocs.yml | 103 + src/docs/couponDocs.yml | 217 + src/docs/orderDocs.yml | 108 + src/docs/vendorOrderManagement.yml | 93 + src/docs/vendorProduct.yml | 235 + src/docs/wishListDocs.yml | 97 + src/entities/Cart.ts | 50 + src/entities/CartItem.ts | 53 + src/entities/Category.ts | 20 + src/entities/Feedback.ts | 30 + src/entities/Notification.ts | 51 + src/entities/NotificationItem.ts | 47 + src/entities/Order.ts | 74 + src/entities/OrderItem.ts | 29 + src/entities/Product.ts | 92 + src/entities/User.ts | 30 +- src/entities/VendorOrderItem.ts | 30 + src/entities/coupon.ts | 68 + src/entities/transaction.ts | 61 + src/entities/vendorOrders.ts | 49 + src/entities/wishList.ts | 35 + src/helper/cartItemValidator.ts | 19 + src/helper/couponValidator.ts | 58 + src/helper/productValidator.ts | 30 + src/index.ts | 24 + src/lib/types.ts | 16 + src/middlewares/errorHandler.ts | 1 - src/middlewares/isAllowed.ts | 48 +- src/middlewares/multer.ts | 14 + src/middlewares/optionalAuthorization.ts | 51 + src/middlewares/verifyToken.ts | 50 + src/node-nlp.d.ts | 1 + src/routes/CartRoutes.ts | 12 + src/routes/NoficationRoutes.ts | 17 + src/routes/ProductRoutes.ts | 58 + src/routes/UserRoutes.ts | 43 +- src/routes/chatBot.ts | 10 + src/routes/couponRoutes.ts | 22 + src/routes/feedbackRoutes.ts | 19 + src/routes/index.ts | 39 +- src/routes/wishListRoute.ts | 31 + src/services/adminOrderServices/readOrder.ts | 158 + .../adminOrderServices/updateOrder.ts | 107 + src/services/cartServices/clearCart.ts | 60 + src/services/cartServices/createCart.ts | 151 + src/services/cartServices/readCart.ts | 58 + .../cartServices/removeProductInCart.ts | 109 + src/services/chatbotServices/chatBot.ts | 30 + .../couponServices/accessAllCoupon.ts | 37 + .../couponServices/buyerApplyCoupon.ts | 87 + .../couponServices/createCouponService.ts | 55 + src/services/couponServices/deleteCoupon.ts | 23 + src/services/couponServices/readCoupon.ts | 23 + src/services/couponServices/updateService.ts | 59 + .../feedbackServices/adminDeleteFeedback.ts | 25 + .../feedbackServices/createFeedback.ts | 44 + .../feedbackServices/deleteFeedback.ts | 27 + .../feedbackServices/updateFeedback.ts | 32 + src/services/index.ts | 51 +- .../deleteNotification.ts | 96 + .../notificationServices/getNotifications.ts | 31 + .../updateNotification.ts | 113 + src/services/orderServices/createOrder.ts | 189 + src/services/orderServices/getOrderService.ts | 119 + .../getOrderTransactionHistory.ts | 42 + .../orderServices/updateOrderService.ts | 170 + src/services/productServices/createProduct.ts | 104 + src/services/productServices/deleteProduct.ts | 30 + .../getRecommendedProductsService.ts | 64 + .../productServices/listAllProductsService.ts | 47 + src/services/productServices/payment.ts | 52 + src/services/productServices/productStatus.ts | 69 + src/services/productServices/readProduct.ts | 78 + .../productServices/removeProductImage.ts | 54 + src/services/productServices/searchProduct.ts | 42 + src/services/productServices/updateProduct.ts | 117 + .../productServices/viewSingleProduct.ts | 31 + .../updateUserStatus/activateUserService.ts | 48 +- .../updateUserStatus/deactivateUserService.ts | 48 +- .../sendResetPasswordLinkService.ts | 77 +- .../userServices/userProfileUpdateServices.ts | 57 + .../vendorOrderServices/readVendorOrder.ts | 119 + .../vendorOrderServices/updateVendorOrder.ts | 87 + src/services/wishListServices/addProduct.ts | 57 + src/services/wishListServices/clearAll.ts | 19 + src/services/wishListServices/getProducts.ts | 39 + .../wishListServices/removeProducts.ts | 21 + src/startups/getSwaggerServer.ts | 2 +- src/train.ts | 43 + src/utils/auth.ts | 66 + src/utils/cloudinary.ts | 13 + src/utils/getNotifications.ts | 27 + src/utils/index.ts | 19 + src/utils/logger.ts | 1 - src/utils/response.utils.ts | 10 +- src/utils/sendNotification.ts | 58 + src/utils/sendOrderMail.ts | 215 + src/utils/sendOrderMailUpdated.ts | 215 + src/utils/sendStatusMail.ts | 19 +- src/utils/socket.ts | 21 + tsconfig.json | 7 +- 174 files changed, 15533 insertions(+), 323 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Intents/cancel.json create mode 100644 Intents/confirm.json create mode 100644 Intents/delay.json create mode 100644 Intents/delivery.json create mode 100644 Intents/greetings.bye.json create mode 100644 Intents/greetings.hello.json create mode 100644 Intents/items.json create mode 100644 Intents/more.contact.json create mode 100644 Intents/more.help.json create mode 100644 Intents/order.json create mode 100644 Intents/payments.json create mode 100644 Intents/personal.json create mode 100644 Intents/profile.info.json create mode 100644 Intents/reason.json create mode 100644 Intents/refund.demanding.json create mode 100644 Intents/refund.status.json create mode 100644 Intents/thanks.json create mode 100644 Intents/track.json create mode 100644 Intents/user.response.json create mode 100644 Intents/voucher.json create mode 100644 "\\" create mode 100644 docker-compose.yml create mode 100644 model.nlp create mode 100644 src/@types/index.d.ts create mode 100644 src/__test__/auth.test.ts create mode 100644 src/__test__/cart.test.ts create mode 100644 src/__test__/chatBot.test.ts create mode 100644 src/__test__/coupon.test.ts create mode 100644 src/__test__/errorHandler.test.ts create mode 100644 src/__test__/getProduct.test.ts create mode 100644 src/__test__/index.test.ts create mode 100644 src/__test__/index.utils.test.ts create mode 100644 src/__test__/logger.test.ts create mode 100644 src/__test__/notification.test.ts create mode 100644 src/__test__/oauth.test.ts create mode 100644 src/__test__/orderManagement.test.ts create mode 100644 src/__test__/product.entities.test.ts create mode 100644 src/__test__/productStatus.test.ts create mode 100644 src/__test__/searchProduct.test.ts create mode 100644 src/__test__/test-assets/DatabaseCleanup.ts create mode 100644 src/__test__/test-assets/photo1.png create mode 100644 src/__test__/test-assets/photo2.webp create mode 100644 src/__test__/user.entity.test.ts create mode 100644 src/__test__/vendorProduct.test.ts create mode 100644 src/__test__/wishList.test.ts create mode 100644 src/controllers/adminOrdercontroller.ts create mode 100644 src/controllers/cartController.ts create mode 100644 src/controllers/chatBotController.ts create mode 100644 src/controllers/couponController.ts create mode 100644 src/controllers/feedbackController.ts create mode 100644 src/controllers/notificationControllers.ts create mode 100644 src/controllers/orderController.ts create mode 100644 src/controllers/productController.ts create mode 100644 src/controllers/vendorOrderController.ts create mode 100644 src/controllers/wishListController.ts create mode 100644 src/docs/adminOrderManagement.yml create mode 100644 src/docs/cartDocs.yml create mode 100644 src/docs/couponDocs.yml create mode 100644 src/docs/orderDocs.yml create mode 100644 src/docs/vendorOrderManagement.yml create mode 100644 src/docs/vendorProduct.yml create mode 100644 src/docs/wishListDocs.yml create mode 100644 src/entities/Cart.ts create mode 100644 src/entities/CartItem.ts create mode 100644 src/entities/Category.ts create mode 100644 src/entities/Feedback.ts create mode 100644 src/entities/Notification.ts create mode 100644 src/entities/NotificationItem.ts create mode 100644 src/entities/Order.ts create mode 100644 src/entities/OrderItem.ts create mode 100644 src/entities/Product.ts create mode 100644 src/entities/VendorOrderItem.ts create mode 100644 src/entities/coupon.ts create mode 100644 src/entities/transaction.ts create mode 100644 src/entities/vendorOrders.ts create mode 100644 src/entities/wishList.ts create mode 100644 src/helper/cartItemValidator.ts create mode 100644 src/helper/couponValidator.ts create mode 100644 src/helper/productValidator.ts create mode 100644 src/lib/types.ts create mode 100644 src/middlewares/multer.ts create mode 100644 src/middlewares/optionalAuthorization.ts create mode 100644 src/middlewares/verifyToken.ts create mode 100644 src/node-nlp.d.ts create mode 100644 src/routes/CartRoutes.ts create mode 100644 src/routes/NoficationRoutes.ts create mode 100644 src/routes/ProductRoutes.ts create mode 100644 src/routes/chatBot.ts create mode 100644 src/routes/couponRoutes.ts create mode 100644 src/routes/feedbackRoutes.ts create mode 100644 src/routes/wishListRoute.ts create mode 100644 src/services/adminOrderServices/readOrder.ts create mode 100644 src/services/adminOrderServices/updateOrder.ts create mode 100644 src/services/cartServices/clearCart.ts create mode 100644 src/services/cartServices/createCart.ts create mode 100644 src/services/cartServices/readCart.ts create mode 100644 src/services/cartServices/removeProductInCart.ts create mode 100644 src/services/chatbotServices/chatBot.ts create mode 100644 src/services/couponServices/accessAllCoupon.ts create mode 100644 src/services/couponServices/buyerApplyCoupon.ts create mode 100644 src/services/couponServices/createCouponService.ts create mode 100644 src/services/couponServices/deleteCoupon.ts create mode 100644 src/services/couponServices/readCoupon.ts create mode 100644 src/services/couponServices/updateService.ts create mode 100644 src/services/feedbackServices/adminDeleteFeedback.ts create mode 100644 src/services/feedbackServices/createFeedback.ts create mode 100644 src/services/feedbackServices/deleteFeedback.ts create mode 100644 src/services/feedbackServices/updateFeedback.ts create mode 100644 src/services/notificationServices/deleteNotification.ts create mode 100644 src/services/notificationServices/getNotifications.ts create mode 100644 src/services/notificationServices/updateNotification.ts create mode 100644 src/services/orderServices/createOrder.ts create mode 100644 src/services/orderServices/getOrderService.ts create mode 100644 src/services/orderServices/getOrderTransactionHistory.ts create mode 100644 src/services/orderServices/updateOrderService.ts create mode 100644 src/services/productServices/createProduct.ts create mode 100644 src/services/productServices/deleteProduct.ts create mode 100644 src/services/productServices/getRecommendedProductsService.ts create mode 100644 src/services/productServices/listAllProductsService.ts create mode 100644 src/services/productServices/payment.ts create mode 100644 src/services/productServices/productStatus.ts create mode 100644 src/services/productServices/readProduct.ts create mode 100644 src/services/productServices/removeProductImage.ts create mode 100644 src/services/productServices/searchProduct.ts create mode 100644 src/services/productServices/updateProduct.ts create mode 100644 src/services/productServices/viewSingleProduct.ts create mode 100644 src/services/userServices/userProfileUpdateServices.ts create mode 100644 src/services/vendorOrderServices/readVendorOrder.ts create mode 100644 src/services/vendorOrderServices/updateVendorOrder.ts create mode 100644 src/services/wishListServices/addProduct.ts create mode 100644 src/services/wishListServices/clearAll.ts create mode 100644 src/services/wishListServices/getProducts.ts create mode 100644 src/services/wishListServices/removeProducts.ts create mode 100644 src/train.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/cloudinary.ts create mode 100644 src/utils/getNotifications.ts create mode 100644 src/utils/sendNotification.ts create mode 100644 src/utils/sendOrderMail.ts create mode 100644 src/utils/sendOrderMailUpdated.ts create mode 100644 src/utils/socket.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2deef83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.env.example b/.env.example index 069130c..5e17a15 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,11 @@ PINDO_API_URL = ******************************** PINDO_SENDER = ******************************** JWT_SECRET = ******************************** TWO_FA_MINS = ******************************** + HOST = ******************* AUTH_EMAIL = ********************* -AUTH_PASSWORD = ****************** \ No newline at end of file +AUTH_PASSWORD = ****************** + +CLOUDNARY_API_KEY = ************** +CLOUDINARY_CLOUD_NAME = ************** +CLOUDINARY_API_SECRET = ************** \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d72e59..77b8aec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,22 @@ env: AUTH_EMAIL: ${{secrets.AUTH_EMAIL}} AUTH_PASSWORD: ${{secrets.AUTH_PASSWORD}} JWT_SECRET: ${{secrets.JWT_SECRET}} - + CLOUDNARY_API_KEY: ${{secrets.CLOUDNARY_API_KEY}} + CLOUDINARY_CLOUD_NAME: ${{secrets.CLOUDINARY_CLOUD_NAME}} + CLOUDINARY_API_SECRET: ${{secrets.CLOUDINARY_API_SECRET}} + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + + TEST_USER_EMAIL: ${{secrets.TEST_USER_EMAIL}} + TEST_USER_PASS: ${{secrets.TEST_USER_PASS}} + TEST_VENDOR_EMAIL: ${{secrets.TEST_VENDOR_EMAIL}} + TEST_VENDOR1_EMAIL: ${{secrets.TEST_VENDOR1_EMAIL}} + TEST_BUYER_EMAIL: ${{secrets.TEST_BUYER_EMAIL}} + TEST_SAMPLE_BUYER_EMAIL: ${{secrets.TEST_SAMPLE_BUYER_EMAIL}} + TEST_VENDOR2_EMAIL: ${{secrets.TEST_VENDOR2_EMAIL}} + + STRIPE_SECRET_KEY: ${{secrets.STRIPE_SECRET_KEYT}} + jobs: build-lint-test-coverage: runs-on: ubuntu-latest @@ -41,4 +56,4 @@ jobs: - name: Upload coverage report to Coveralls uses: coverallsapp/github-action@v2.2.3 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1500c37..829a739 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,14 @@ package-lock.json coverage/ dist /src/logs -.DS_Store \ No newline at end of file +.DS_Store + + src/controllers/notificationControllers.ts + src/entities/Notification.ts + src/entities/NotificationItem.ts + src/routes/NoficationRoutes.ts + src/services/notificationServices/deleteNotification.ts + src/services/notificationServices/getNotifications.ts + src/services/notificationServices/updateNotification.ts + src/utils/getNotifications.ts + src/utils/sendNotification.ts \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5190e01 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json . + +RUN npm install + +COPY . . + +EXPOSE $PORT + +CMD ["npm", "run", "dev"] diff --git a/Intents/cancel.json b/Intents/cancel.json new file mode 100644 index 0000000..0171b1b --- /dev/null +++ b/Intents/cancel.json @@ -0,0 +1,14 @@ +{ + "intent": "cancel", + "utterances": [ + "Can I cancel my order?", + "Why is my order cancelled?", + "How do I can cancel my order", + "Can I cancel an order" + ], + "responses": [ + "Order can only be cancelled within 7 days of placement. Digital goods do not qualify for refund.", + "Visit my orders page to check status of specific orders." + ] + } + \ No newline at end of file diff --git a/Intents/confirm.json b/Intents/confirm.json new file mode 100644 index 0000000..5c708ec --- /dev/null +++ b/Intents/confirm.json @@ -0,0 +1,14 @@ +{ + "intent": "confirm", + "utterances": [ + "My payment was processed successfully but I didn't get any order confirmation. What should I do?", + "Payment was completed but no confirmation", + "Order was not confirmed", + "Money deducted but order is not confirmed" + ], + "responses": [ + "knight sends you an Email & SMS upon confirmation of your order. If it requires manual confirmation, our team will contact you within 24 hours after order placement. Delay in confirmation SMS may occur due to network error, you may receive it with a delay.", + "It takes upto 24 hours for confirmation, please bear with us! Type Need more help for more assistance. " + ] + } + \ No newline at end of file diff --git a/Intents/delay.json b/Intents/delay.json new file mode 100644 index 0000000..52d0cdf --- /dev/null +++ b/Intents/delay.json @@ -0,0 +1,16 @@ +{ + "intent": "delay", + "utterances": [ + "Why is my order delayed? ", + "Did my order get delayed?", + "Why is my order not delivered yet?", + "When do I get my delivery?" + ], + "responses": [ + "We are really sorry if there has been a delay in your order! If your order is within delivery period, We recommend you to please wait during this period since our Delivery Heroes make 3 attempts to deliver your order!", + "If your order is past up to 3 days after delivery period, There may be logistic issues causing a delay in delivery. Please type 'Common reasons for delivery delay' to know more about this.", + "We appreciate if you could wait for your items as most orders are delivered successfully within this period.", + "If your order is past more than 3 days, Since there may be unexpected issues causing delivery delays, you can click on 'Need more Help' for further assistance." + ] + } + \ No newline at end of file diff --git a/Intents/delivery.json b/Intents/delivery.json new file mode 100644 index 0000000..d3b969b --- /dev/null +++ b/Intents/delivery.json @@ -0,0 +1,15 @@ +{ + "intent": "delivery", + "utterances": [ + "How long does delivery take?", + "How long does shipping take?", + "Please Tell me about my delivery", + "When do I get my delivery?", + "Why is my order not delivered yet" + ], + "responses": [ + "Delivery takes 2-4 days. Please bear with us!", + "Shipping takes 2-4 days. Please bear with us!" + ] + } + \ No newline at end of file diff --git a/Intents/greetings.bye.json b/Intents/greetings.bye.json new file mode 100644 index 0000000..b9aa6f0 --- /dev/null +++ b/Intents/greetings.bye.json @@ -0,0 +1,18 @@ +{ + "intent": "goodbye", + "utterances": [ + "goodbye", + "bye take care", + "see you later", + "bye for now", + "i must go" + ], + "responses": [ + "see you soon!", + "Till next time", + "bye bye", + "have a great day", + "See you later, thanks for visiting. Hope I was able to help!", + "Have a nice day. Hope I was able to help!" + ] +} \ No newline at end of file diff --git a/Intents/greetings.hello.json b/Intents/greetings.hello.json new file mode 100644 index 0000000..7926f49 --- /dev/null +++ b/Intents/greetings.hello.json @@ -0,0 +1,19 @@ +{ + "intent": "greetings", + "utterances": [ + "hello", + "hi", + "howdy", + "Greetings", + "Is anyone there?", + "Hello", + "Good day" + ], + "responses": [ + "Hey :-) My name is knight!", + "Hello, thanks for visiting. My name is knight!", + "Hi there, My name is knight!. What can I do for you?", + "Hi there, My name is knight! How can I help?" + ] + } + \ No newline at end of file diff --git a/Intents/items.json b/Intents/items.json new file mode 100644 index 0000000..3a34861 --- /dev/null +++ b/Intents/items.json @@ -0,0 +1,23 @@ +{ + "intent": "items", + "utterances": [ + "Which items do you have?", + "What kinds of items are there?", + "What do you sell?", + "What do you offer?", + "What can I buy?", + "I'm looking for...", + "Do you have any...", + "I'm interested in...", + "Can I see what you have in...", + "I want to buy a...", + "I'm looking for something like this...", + "What are your most popular items?", + "What are some of your best deals?", + "Do you have any new arrivals?" + ], + "responses": [ + "Search your preference in our flagship store's search bar to see all available products. " + ] + } + \ No newline at end of file diff --git a/Intents/more.contact.json b/Intents/more.contact.json new file mode 100644 index 0000000..df773ac --- /dev/null +++ b/Intents/more.contact.json @@ -0,0 +1,12 @@ +{ + "intent": "more_contact", + "utterances": [ + "Need more help", + "Help me more" + ], + "responses": [ + "Absolutely! How can I help you today? Here are some options based on your inquiry:", + "Sure, let me know what you need help with. Here are a few things I can assist you with:" + ] + } + \ No newline at end of file diff --git a/Intents/more.help.json b/Intents/more.help.json new file mode 100644 index 0000000..3ce18e9 --- /dev/null +++ b/Intents/more.help.json @@ -0,0 +1,19 @@ +{ + "intent": "more_help", + "utterances": [ + "can I talk to an agent", + "can I call customer service", + "customer support number", + "how to contact customer service", + "customer service number", + "contact number for help", + "helpline number", + "How to become a seller", + "How to contact a seller" + ], + "responses": [ + "Contact us for further information here: Phone: +250 780 000 000. Timings are from 09:00 AM to 05:00 PM from Monday to Saturday.", + "For immediate assistance, you can contact our customer service team at +250 780 000 000. Our hours are from 09:00 AM to 05:00 PM, Monday to Saturday." + ] + } + \ No newline at end of file diff --git a/Intents/order.json b/Intents/order.json new file mode 100644 index 0000000..b110674 --- /dev/null +++ b/Intents/order.json @@ -0,0 +1,12 @@ +{ + "intent": "order_status", + "utterances": [ + "What is my order status", + "I want to know my return status", + "How to return status" + ], + "responses": [ + "Please visit the My Orders page for a list of your confirmed orders." + ] + } + \ No newline at end of file diff --git a/Intents/payments.json b/Intents/payments.json new file mode 100644 index 0000000..2243fa3 --- /dev/null +++ b/Intents/payments.json @@ -0,0 +1,15 @@ +{ + "intent": "payments", + "utterances": [ + "Do you take credit cards?", + "Do you accept Mastercard?", + "Can I pay with Cash?", + "Are you cash only?", + "What are your payment methods?", + "How do I pay?" + ], + "responses": [ + "We accept VISA and Mastercard" + ] + } + \ No newline at end of file diff --git a/Intents/personal.json b/Intents/personal.json new file mode 100644 index 0000000..fa32b95 --- /dev/null +++ b/Intents/personal.json @@ -0,0 +1,12 @@ +{ + "intent": "personal", + "utterances": [ + "How are you?", + "How are you doing?", + "How is your day?" + ], + "responses": [ + "I'm good, all's good, thanks. How about you?" + ] + } + \ No newline at end of file diff --git a/Intents/profile.info.json b/Intents/profile.info.json new file mode 100644 index 0000000..229b6ac --- /dev/null +++ b/Intents/profile.info.json @@ -0,0 +1,15 @@ +{ + "intent": "profile_info", + "utterances": [ + "How can I change my profile information", + "I want to change my password", + "I want to change my phone number", + "I want to change my address", + "I want to Reset my password", + "I want to delete my account", + "delete my account" + ], + "responses": [ + "You can easily add or change your account details by following the steps below: Step 1: Click on 'Account', Step 2: Click on 'Manage my account' from the icon, Step 3: You can change or edit your name, address, email address, mobile number, etc., Step 4: Fill in the required details, and click on Save. Note: You can also change your delivery address from the Checkout page before proceeding to pay." + ] + } \ No newline at end of file diff --git a/Intents/reason.json b/Intents/reason.json new file mode 100644 index 0000000..4c7b831 --- /dev/null +++ b/Intents/reason.json @@ -0,0 +1,13 @@ +{ + "intent": "reasons", + "utterances": [ + "Common reasons for delivery delay", + "common reasons for delivery delay", + "reasons for delay", + "delivery delay" + ], + "responses": [ + "Reasons include Seller Sourcing Issues, Courier Issues, Cross Border shipment delay, Wrong Address or Phone Number, and Unavailability of Customer. " + ] + } + \ No newline at end of file diff --git a/Intents/refund.demanding.json b/Intents/refund.demanding.json new file mode 100644 index 0000000..70e004e --- /dev/null +++ b/Intents/refund.demanding.json @@ -0,0 +1,14 @@ +{ + "intent": "demandin_refund", + "utterances": [ + "Can I refund an item.", + "I want to refund an item", + "can I refund my order", + "Are refunds available" + ], + "responses": [ + "Refund can only be issued within 7 days of placement. Digital goods do not qualify for refund.", + "Visit my orders page to check for specific orders." + ] + } + \ No newline at end of file diff --git a/Intents/refund.status.json b/Intents/refund.status.json new file mode 100644 index 0000000..a1f1361 --- /dev/null +++ b/Intents/refund.status.json @@ -0,0 +1,14 @@ +{ + "intent": "refund_status", + "utterances": [ + "Why is the status Refunded when it's not credited?", + "No refund even though status is refunded", + "No refund when status says refunded", + "I did not receive my refund money", + "Refund money not received" + ], + "responses": [ + "Please be patient as refunds take upto 30 days to receive into bank. " + ] + } + diff --git a/Intents/thanks.json b/Intents/thanks.json new file mode 100644 index 0000000..4d0fbb0 --- /dev/null +++ b/Intents/thanks.json @@ -0,0 +1,13 @@ +{ + "intent": "thanks", + "utterances": ["Thanks", + "Thank you", + "That's helpful", + "Thank's a lot!", + "thx", + "thnks"], + "responses": ["Happy to help!", + "Any time!", + "My pleasure"] + } + \ No newline at end of file diff --git a/Intents/track.json b/Intents/track.json new file mode 100644 index 0000000..a130950 --- /dev/null +++ b/Intents/track.json @@ -0,0 +1,13 @@ +{ + "intent": "track", + "utterances": [ + "How can I track my order", + "I want to track my order", + "Can I track my order", + "Track order" + ], + "responses": [ + "Visit the order page, click on the specific order, select 'track my order', and check the status" + ] + } + \ No newline at end of file diff --git a/Intents/user.response.json b/Intents/user.response.json new file mode 100644 index 0000000..998d545 --- /dev/null +++ b/Intents/user.response.json @@ -0,0 +1,14 @@ +{ + "intent": "user_response", + "utterances": [ + "I'm good", + "Im good", + "Im doing good", + "I am good", + "I am okay" + ], + "responses": [ + "Great to hear you are doing good." + ] + } + \ No newline at end of file diff --git a/Intents/voucher.json b/Intents/voucher.json new file mode 100644 index 0000000..cc75c1f --- /dev/null +++ b/Intents/voucher.json @@ -0,0 +1,12 @@ +{ + "intent": "use_voucher", + "utterances": [ + "How to use a voucher?", + "Can I use a voucher?", + "How to use a voucher?" + ], + "responses": [ + "You can add a voucher by clicking on My Cart > Check Out > Enter Voucher Code > APPLY. " + ] + } + \ No newline at end of file diff --git a/README.md b/README.md index 755652f..453c9d5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ functionalities for the frontend, such as storing, retrieving, deleting data and ## Documentation -List of endpoints exposed by the service +[List of endpoints exposed by the service](https://knights-ecomm-be-lcdh.onrender.com/api/v1/docs/) ## Setup @@ -72,6 +72,24 @@ logger.debug('This is a debug message'); npm test ``` +### Setting up docker and using it + +- Download and install docker + ``` + https://www.docker.com/products/docker-desktop/ + ``` +- Download Subsystem for Linux for none linux users +- Set environment varibles like database host to postgresdb + +- Building the image, you must navigate to the project directory in the terminal, then run + ``` + docker-compose up --build + ``` +- Stoping docker-compose container, run + ``` + docker-compose down + ``` + ## Authors - [Maxime Mizero](https://github.com/maxCastro1) diff --git "a/\\" "b/\\" new file mode 100644 index 0000000..29702f9 --- /dev/null +++ "b/\\" @@ -0,0 +1,55 @@ +Merge branch 'develop' of https://github.com/atlp-rwanda/knights-ecomm-be into ft-coverage + +# Conflicts: +# src/routes/index.ts +# +# It looks like you may be committing a merge. +# If this is not correct, please remove the file +# .git/MERGE_HEAD +# and try again. + + +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# On branch ft-coverage +# Your branch is up to date with 'origin/ft-coverage'. +# +# All conflicts fixed but you are still merging. +# +# Changes to be committed: +# modified: package.json +# modified: src/__test__/cart.test.ts +# new file: src/__test__/isValid.test.ts +# new file: src/__test__/login.service.test.ts +# new file: src/__test__/notification.test.ts +# modified: src/__test__/test-assets/DatabaseCleanup.ts +# new file: src/__test__/user,Route.test.ts +# new file: src/__test__/user.profile.update.service.test.ts +# new file: src/controllers/notificationControllers.ts +# modified: src/controllers/orderController.ts +# new file: src/docs/notifications.yml +# modified: src/docs/orderDocs.yml +# new file: src/entities/Notification.ts +# new file: src/entities/NotificationItem.ts +# modified: src/entities/VendorOrderItem.ts +# modified: src/helper/verify.ts +# new file: src/routes/NoficationRoutes.ts +# modified: src/routes/ProductRoutes.ts +# modified: src/routes/index.ts +# modified: src/services/adminOrderServices/updateOrder.ts +# modified: src/services/couponServices/buyerApplyCoupon.ts +# modified: src/services/couponServices/createCouponService.ts +# modified: src/services/feedbackServices/createFeedback.ts +# modified: src/services/index.ts +# new file: src/services/notificationServices/deleteNotification.ts +# new file: src/services/notificationServices/getNotifications.ts +# new file: src/services/notificationServices/updateNotification.ts +# modified: src/services/orderServices/createOrder.ts +# modified: src/services/orderServices/getOrderService.ts +# modified: src/services/orderServices/updateOrderService.ts +# modified: src/services/userServices/userDisableTwoFactorAuth.ts +# modified: src/services/userServices/userEnableTwoFactorAuth.ts +# new file: src/utils/getNotifications.ts +# new file: src/utils/sendNotification.ts +# diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..99ed647 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + postgresdb: + image: postgres + environment: + POSTGRES_USER: $DEV_DB_USER + POSTGRES_PASSWORD: $DEV_DB_PASS + POSTGRES_DB: $DEV_DB_NAME + volumes: + - knights-data:/var/lib/postgresql/data + + node-app: + build: . + volumes: + - .:/app + - /app/node_modules + image: knights-app:1.0 + env_file: + - ./.env + ports: + - $PORT:$PORT + depends_on: + - postgresdb + +volumes: + knights-data: diff --git a/jest.config.ts b/jest.config.ts index 296ffe3..9ef429f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,10 +6,11 @@ export default { verbose: true, forceExit: true, clearMocks: true, + testTimeout: 30000, resetMocks: true, restoreMocks: true, collectCoverageFrom: [ - 'src/**/*.{ts,tsx}', // Include all JavaScript/JSX files in the src directory + 'src/services/**/*.{ts,tsx}', // Include all JavaScript/JSX files in the src directory ], coveragePathIgnorePatterns: [ '/node_modules/', // Exclude the node_modules directory diff --git a/model.nlp b/model.nlp new file mode 100644 index 0000000..f1df53b --- /dev/null +++ b/model.nlp @@ -0,0 +1,4388 @@ +{ + "settings": { + "languages": [ + "en" + ] + }, + "nluManager": { + "settings": {}, + "languages": [ + "en" + ], + "intentDomains": {}, + "domainManagers": { + "en": { + "settings": { + "language": "en", + "useNoneFeature": true, + "config": { + "activation": "leaky-relu", + "hiddenLayers": [], + "iterations": 20000, + "learningRate": 0.7, + "errorThresh": 0.00005, + "momentum": 0.5, + "deltaErrorThresh": 0.000001, + "maxDecimals": 9, + "leakyReluAlpha": 0.08, + "timeout": 120000 + } + }, + "language": "en", + "nluClassName": "BrainNLU", + "useMasterDomain": true, + "trainByDomain": false, + "keepStopwords": true, + "stemDict": { + "can,cancel,i,my,order": { + "domain": "default", + "intent": "cancel" + }, + "cancel,is,my,order,whi": { + "domain": "default", + "intent": "cancel" + }, + "can,cancel,do,how,i,my,order": { + "domain": "default", + "intent": "cancel" + }, + "an,can,cancel,i,order": { + "domain": "default", + "intent": "cancel" + }, + "ani,but,confirm,did,do,get,i,i,my,not,order,payment,process,should,success,was,what": { + "domain": "default", + "intent": "confirm" + }, + "but,complet,confirm,no,payment,was": { + "domain": "default", + "intent": "confirm" + }, + "confirm,not,order,was": { + "domain": "default", + "intent": "confirm" + }, + "but,confirm,deduct,is,money,not,order": { + "domain": "default", + "intent": "confirm" + }, + "delay,is,my,order,whi": { + "domain": "default", + "intent": "delay" + }, + "delay,did,get,my,order": { + "domain": "default", + "intent": "delay" + }, + "deliveri,doe,how,long,take": { + "domain": "default", + "intent": "delivery" + }, + "doe,how,long,ship,take": { + "domain": "default", + "intent": "delivery" + }, + "about,deliveri,me,my,pleas,tell": { + "domain": "default", + "intent": "delivery" + }, + "deliveri,do,get,i,my,when": { + "domain": "default", + "intent": "delivery" + }, + "deliv,is,my,not,order,whi,yet": { + "domain": "default", + "intent": "delivery" + }, + "goodby": { + "domain": "default", + "intent": "greetings.bye" + }, + "bye,care,take": { + "domain": "default", + "intent": "greetings.bye" + }, + "later,see,you": { + "domain": "default", + "intent": "greetings.bye" + }, + "bye,for,now": { + "domain": "default", + "intent": "greetings.bye" + }, + "go,i,must": { + "domain": "default", + "intent": "greetings.bye" + }, + "hi": { + "domain": "default", + "intent": "greetings.hello" + }, + "howdi": { + "domain": "default", + "intent": "greetings.hello" + }, + "greet": { + "domain": "default", + "intent": "greetings.hello" + }, + "anyon,is,there": { + "domain": "default", + "intent": "greetings.hello" + }, + "hello": { + "domain": "default", + "intent": "greetings.hello" + }, + "day,good": { + "domain": "default", + "intent": "greetings.hello" + }, + "do,have,item,which,you": { + "domain": "default", + "intent": "items" + }, + "are,item,kind,of,there,what": { + "domain": "default", + "intent": "items" + }, + "do,sell,what,you": { + "domain": "default", + "intent": "items" + }, + "do,offer,what,you": { + "domain": "default", + "intent": "items" + }, + "buy,can,i,what": { + "domain": "default", + "intent": "items" + }, + "am,for,i,look": { + "domain": "default", + "intent": "items" + }, + "ani,do,have,you": { + "domain": "default", + "intent": "items" + }, + "am,i,in,interest": { + "domain": "default", + "intent": "items" + }, + "can,have,i,in,see,what,you": { + "domain": "default", + "intent": "items" + }, + "a,buy,i,to,want": { + "domain": "default", + "intent": "items" + }, + "am,for,i,like,look,someth,this": { + "domain": "default", + "intent": "items" + }, + "are,item,most,popular,what,your": { + "domain": "default", + "intent": "items" + }, + "are,best,deal,of,some,what,your": { + "domain": "default", + "intent": "items" + }, + "ani,arriv,do,have,new,you": { + "domain": "default", + "intent": "items" + }, + "help,more,need": { + "domain": "default", + "intent": "more.contact" + }, + "help,me,more": { + "domain": "default", + "intent": "more.contact" + }, + "agent,an,can,i,talk,to": { + "domain": "default", + "intent": "more.help" + }, + "call,can,custom,i,servic": { + "domain": "default", + "intent": "more.help" + }, + "custom,number,support": { + "domain": "default", + "intent": "more.help" + }, + "contact,custom,how,servic,to": { + "domain": "default", + "intent": "more.help" + }, + "custom,number,servic": { + "domain": "default", + "intent": "more.help" + }, + "contact,for,help,number": { + "domain": "default", + "intent": "more.help" + }, + "helplin,number": { + "domain": "default", + "intent": "more.help" + }, + "a,becom,how,seller,to": { + "domain": "default", + "intent": "more.help" + }, + "a,contact,how,seller,to": { + "domain": "default", + "intent": "more.help" + }, + "is,my,order,status,what": { + "domain": "default", + "intent": "order" + }, + "i,know,my,return,status,to,want": { + "domain": "default", + "intent": "order" + }, + "how,return,status,to": { + "domain": "default", + "intent": "order" + }, + "card,credit,do,take,you": { + "domain": "default", + "intent": "payments" + }, + "accept,do,mastercard,you": { + "domain": "default", + "intent": "payments" + }, + "can,cash,i,pay,with": { + "domain": "default", + "intent": "payments" + }, + "are,cash,onli,you": { + "domain": "default", + "intent": "payments" + }, + "are,method,payment,what,your": { + "domain": "default", + "intent": "payments" + }, + "do,how,i,pay": { + "domain": "default", + "intent": "payments" + }, + "are,how,you": { + "domain": "default", + "intent": "personal" + }, + "are,do,how,you": { + "domain": "default", + "intent": "personal" + }, + "day,how,is,your": { + "domain": "default", + "intent": "personal" + }, + "can,chang,how,i,inform,my,profil": { + "domain": "default", + "intent": "profile.info" + }, + "chang,i,my,password,to,want": { + "domain": "default", + "intent": "profile.info" + }, + "chang,i,my,number,phone,to,want": { + "domain": "default", + "intent": "profile.info" + }, + "address,chang,i,my,to,want": { + "domain": "default", + "intent": "profile.info" + }, + "i,my,password,reset,to,want": { + "domain": "default", + "intent": "profile.info" + }, + "account,delet,i,my,to,want": { + "domain": "default", + "intent": "profile.info" + }, + "account,delet,my": { + "domain": "default", + "intent": "profile.info" + }, + "common,delay,deliveri,for,reason": { + "domain": "default", + "intent": "reason" + }, + "delay,for,reason": { + "domain": "default", + "intent": "reason" + }, + "delay,deliveri": { + "domain": "default", + "intent": "reason" + }, + "an,can,i,item,refund": { + "domain": "default", + "intent": "refund.demanding" + }, + "an,i,item,refund,to,want": { + "domain": "default", + "intent": "refund.demanding" + }, + "can,i,my,order,refund": { + "domain": "default", + "intent": "refund.demanding" + }, + "are,avail,refund": { + "domain": "default", + "intent": "refund.demanding" + }, + "credit,is,is,it,not,refund,status,the,when,whi": { + "domain": "default", + "intent": "refund.status" + }, + "even,is,no,refund,refund,status,though": { + "domain": "default", + "intent": "refund.status" + }, + "no,refund,refund,say,status,when": { + "domain": "default", + "intent": "refund.status" + }, + "did,i,money,my,not,receiv,refund": { + "domain": "default", + "intent": "refund.status" + }, + "money,not,receiv,refund": { + "domain": "default", + "intent": "refund.status" + }, + "thank": { + "domain": "default", + "intent": "thanks" + }, + "thank,you": { + "domain": "default", + "intent": "thanks" + }, + "help,is,that": { + "domain": "default", + "intent": "thanks" + }, + "a,is,lot,thank": { + "domain": "default", + "intent": "thanks" + }, + "thx": { + "domain": "default", + "intent": "thanks" + }, + "thnks": { + "domain": "default", + "intent": "thanks" + }, + "can,how,i,my,order,track": { + "domain": "default", + "intent": "track" + }, + "i,my,order,to,track,want": { + "domain": "default", + "intent": "track" + }, + "can,i,my,order,track": { + "domain": "default", + "intent": "track" + }, + "order,track": { + "domain": "default", + "intent": "track" + }, + "good,im": { + "domain": "default", + "intent": "user.response" + }, + "do,good,im": { + "domain": "default", + "intent": "user.response" + }, + "am,good,i": { + "domain": "default", + "intent": "user.response" + }, + "am,i,okay": { + "domain": "default", + "intent": "user.response" + }, + "a,can,i,use,voucher": { + "domain": "default", + "intent": "voucher" + }, + "a,how,to,use,voucher": { + "domain": "default", + "intent": "voucher" + } + }, + "intentDict": { + "cancel": "default", + "confirm": "default", + "delay": "default", + "delivery": "default", + "greetings.bye": "default", + "greetings.hello": "default", + "items": "default", + "more.contact": "default", + "more.help": "default", + "order": "default", + "payments": "default", + "personal": "default", + "profile.info": "default", + "reason": "default", + "refund.demanding": "default", + "refund.status": "default", + "thanks": "default", + "track": "default", + "user.response": "default", + "voucher": "default" + }, + "useStemDict": true, + "domains": { + "master_domain": { + "settings": { + "language": "en", + "useNoneFeature": true, + "config": { + "activation": "leaky-relu", + "hiddenLayers": [], + "iterations": 20000, + "learningRate": 0.7, + "errorThresh": 0.00005, + "momentum": 0.5, + "deltaErrorThresh": 0.000001, + "maxDecimals": 9, + "leakyReluAlpha": 0.08, + "timeout": 120000 + } + }, + "language": "en", + "keepStopwords": true, + "docs": [ + { + "intent": "cancel", + "tokens": [ + "can", + "i", + "cancel", + "my", + "order" + ] + }, + { + "intent": "cancel", + "tokens": [ + "whi", + "is", + "my", + "order", + "cancel" + ] + }, + { + "intent": "cancel", + "tokens": [ + "how", + "do", + "i", + "can", + "cancel", + "my", + "order" + ] + }, + { + "intent": "cancel", + "tokens": [ + "can", + "i", + "cancel", + "an", + "order" + ] + }, + { + "intent": "confirm", + "tokens": [ + "my", + "payment", + "was", + "process", + "success", + "but", + "i", + "did", + "not", + "get", + "ani", + "order", + "confirm", + "what", + "should", + "i", + "do" + ] + }, + { + "intent": "confirm", + "tokens": [ + "payment", + "was", + "complet", + "but", + "no", + "confirm" + ] + }, + { + "intent": "confirm", + "tokens": [ + "order", + "was", + "not", + "confirm" + ] + }, + { + "intent": "confirm", + "tokens": [ + "money", + "deduct", + "but", + "order", + "is", + "not", + "confirm" + ] + }, + { + "intent": "delay", + "tokens": [ + "whi", + "is", + "my", + "order", + "delay" + ] + }, + { + "intent": "delay", + "tokens": [ + "did", + "my", + "order", + "get", + "delay" + ] + }, + { + "intent": "delivery", + "tokens": [ + "how", + "long", + "doe", + "deliveri", + "take" + ] + }, + { + "intent": "delivery", + "tokens": [ + "how", + "long", + "doe", + "ship", + "take" + ] + }, + { + "intent": "delivery", + "tokens": [ + "pleas", + "tell", + "me", + "about", + "my", + "deliveri" + ] + }, + { + "intent": "greetings.bye", + "tokens": [ + "goodby" + ] + }, + { + "intent": "greetings.bye", + "tokens": [ + "bye", + "take", + "care" + ] + }, + { + "intent": "greetings.bye", + "tokens": [ + "see", + "you", + "later" + ] + }, + { + "intent": "greetings.bye", + "tokens": [ + "bye", + "for", + "now" + ] + }, + { + "intent": "greetings.bye", + "tokens": [ + "i", + "must", + "go" + ] + }, + { + "intent": "greetings.hello", + "tokens": [ + "hi" + ] + }, + { + "intent": "greetings.hello", + "tokens": [ + "howdi" + ] + }, + { + "intent": "greetings.hello", + "tokens": [ + "greet" + ] + }, + { + "intent": "greetings.hello", + "tokens": [ + "is", + "anyon", + "there" + ] + }, + { + "intent": "greetings.hello", + "tokens": [ + "good", + "day" + ] + }, + { + "intent": "items", + "tokens": [ + "which", + "item", + "do", + "you", + "have" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "kind", + "of", + "item", + "are", + "there" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "do", + "you", + "sell" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "do", + "you", + "offer" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "can", + "i", + "buy" + ] + }, + { + "intent": "items", + "tokens": [ + "i", + "am", + "look", + "for" + ] + }, + { + "intent": "items", + "tokens": [ + "do", + "you", + "have", + "ani" + ] + }, + { + "intent": "items", + "tokens": [ + "i", + "am", + "interest", + "in" + ] + }, + { + "intent": "items", + "tokens": [ + "can", + "i", + "see", + "what", + "you", + "have", + "in" + ] + }, + { + "intent": "items", + "tokens": [ + "i", + "want", + "to", + "buy", + "a" + ] + }, + { + "intent": "items", + "tokens": [ + "i", + "am", + "look", + "for", + "someth", + "like", + "this" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "are", + "your", + "most", + "popular", + "item" + ] + }, + { + "intent": "items", + "tokens": [ + "what", + "are", + "some", + "of", + "your", + "best", + "deal" + ] + }, + { + "intent": "items", + "tokens": [ + "do", + "you", + "have", + "ani", + "new", + "arriv" + ] + }, + { + "intent": "more.contact", + "tokens": [ + "need", + "more", + "help" + ] + }, + { + "intent": "more.contact", + "tokens": [ + "help", + "me", + "more" + ] + }, + { + "intent": "more.help", + "tokens": [ + "can", + "i", + "talk", + "to", + "an", + "agent" + ] + }, + { + "intent": "more.help", + "tokens": [ + "can", + "i", + "call", + "custom", + "servic" + ] + }, + { + "intent": "more.help", + "tokens": [ + "custom", + "support", + "number" + ] + }, + { + "intent": "more.help", + "tokens": [ + "how", + "to", + "contact", + "custom", + "servic" + ] + }, + { + "intent": "more.help", + "tokens": [ + "custom", + "servic", + "number" + ] + }, + { + "intent": "more.help", + "tokens": [ + "contact", + "number", + "for", + "help" + ] + }, + { + "intent": "more.help", + "tokens": [ + "helplin", + "number" + ] + }, + { + "intent": "more.help", + "tokens": [ + "how", + "to", + "becom", + "a", + "seller" + ] + }, + { + "intent": "more.help", + "tokens": [ + "how", + "to", + "contact", + "a", + "seller" + ] + }, + { + "intent": "order", + "tokens": [ + "what", + "is", + "my", + "order", + "status" + ] + }, + { + "intent": "order", + "tokens": [ + "i", + "want", + "to", + "know", + "my", + "return", + "status" + ] + }, + { + "intent": "order", + "tokens": [ + "how", + "to", + "return", + "status" + ] + }, + { + "intent": "payments", + "tokens": [ + "do", + "you", + "take", + "credit", + "card" + ] + }, + { + "intent": "payments", + "tokens": [ + "do", + "you", + "accept", + "mastercard" + ] + }, + { + "intent": "payments", + "tokens": [ + "can", + "i", + "pay", + "with", + "cash" + ] + }, + { + "intent": "payments", + "tokens": [ + "are", + "you", + "cash", + "onli" + ] + }, + { + "intent": "payments", + "tokens": [ + "what", + "are", + "your", + "payment", + "method" + ] + }, + { + "intent": "payments", + "tokens": [ + "how", + "do", + "i", + "pay" + ] + }, + { + "intent": "personal", + "tokens": [ + "how", + "are", + "you" + ] + }, + { + "intent": "personal", + "tokens": [ + "how", + "are", + "you", + "do" + ] + }, + { + "intent": "personal", + "tokens": [ + "how", + "is", + "your", + "day" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "how", + "can", + "i", + "chang", + "my", + "profil", + "inform" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "i", + "want", + "to", + "chang", + "my", + "password" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "i", + "want", + "to", + "chang", + "my", + "phone", + "number" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "i", + "want", + "to", + "chang", + "my", + "address" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "i", + "want", + "to", + "reset", + "my", + "password" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "i", + "want", + "to", + "delet", + "my", + "account" + ] + }, + { + "intent": "profile.info", + "tokens": [ + "delet", + "my", + "account" + ] + }, + { + "intent": "reason", + "tokens": [ + "common", + "reason", + "for", + "deliveri", + "delay" + ] + }, + { + "intent": "reason", + "tokens": [ + "reason", + "for", + "delay" + ] + }, + { + "intent": "reason", + "tokens": [ + "deliveri", + "delay" + ] + }, + { + "intent": "refund.demanding", + "tokens": [ + "can", + "i", + "refund", + "an", + "item" + ] + }, + { + "intent": "refund.demanding", + "tokens": [ + "i", + "want", + "to", + "refund", + "an", + "item" + ] + }, + { + "intent": "refund.demanding", + "tokens": [ + "can", + "i", + "refund", + "my", + "order" + ] + }, + { + "intent": "refund.demanding", + "tokens": [ + "are", + "refund", + "avail" + ] + }, + { + "intent": "refund.status", + "tokens": [ + "whi", + "is", + "the", + "status", + "refund", + "when", + "it", + "is", + "not", + "credit" + ] + }, + { + "intent": "refund.status", + "tokens": [ + "no", + "refund", + "even", + "though", + "status", + "is", + "refund" + ] + }, + { + "intent": "refund.status", + "tokens": [ + "no", + "refund", + "when", + "status", + "say", + "refund" + ] + }, + { + "intent": "refund.status", + "tokens": [ + "i", + "did", + "not", + "receiv", + "my", + "refund", + "money" + ] + }, + { + "intent": "refund.status", + "tokens": [ + "refund", + "money", + "not", + "receiv" + ] + }, + { + "intent": "thanks", + "tokens": [ + "thank" + ] + }, + { + "intent": "thanks", + "tokens": [ + "thank", + "you" + ] + }, + { + "intent": "thanks", + "tokens": [ + "that", + "is", + "help" + ] + }, + { + "intent": "thanks", + "tokens": [ + "thank", + "is", + "a", + "lot" + ] + }, + { + "intent": "thanks", + "tokens": [ + "thx" + ] + }, + { + "intent": "thanks", + "tokens": [ + "thnks" + ] + }, + { + "intent": "track", + "tokens": [ + "how", + "can", + "i", + "track", + "my", + "order" + ] + }, + { + "intent": "track", + "tokens": [ + "i", + "want", + "to", + "track", + "my", + "order" + ] + }, + { + "intent": "track", + "tokens": [ + "can", + "i", + "track", + "my", + "order" + ] + }, + { + "intent": "track", + "tokens": [ + "track", + "order" + ] + }, + { + "intent": "user.response", + "tokens": [ + "im", + "good" + ] + }, + { + "intent": "user.response", + "tokens": [ + "im", + "do", + "good" + ] + }, + { + "intent": "user.response", + "tokens": [ + "i", + "am", + "okay" + ] + }, + { + "intent": "voucher", + "tokens": [ + "can", + "i", + "use", + "a", + "voucher" + ] + } + ], + "features": { + "can": 14, + "i": 32, + "cancel": 4, + "my": 21, + "order": 15, + "whi": 3, + "is": 11, + "how": 13, + "do": 12, + "an": 4, + "payment": 3, + "was": 3, + "process": 1, + "success": 1, + "but": 3, + "did": 3, + "not": 6, + "get": 2, + "ani": 3, + "confirm": 4, + "what": 10, + "should": 1, + "complet": 1, + "no": 3, + "money": 3, + "deduct": 1, + "delay": 5, + "deliveri": 4, + "long": 2, + "doe": 2, + "take": 4, + "ship": 1, + "pleas": 1, + "tell": 1, + "me": 2, + "about": 1, + "goodby": 1, + "bye": 2, + "care": 1, + "see": 2, + "you": 13, + "later": 1, + "for": 6, + "now": 1, + "must": 1, + "go": 1, + "hi": 1, + "howdi": 1, + "greet": 1, + "anyon": 1, + "there": 2, + "good": 3, + "day": 2, + "which": 1, + "item": 5, + "have": 4, + "kind": 1, + "of": 2, + "are": 8, + "sell": 1, + "offer": 1, + "buy": 2, + "am": 4, + "look": 2, + "interest": 1, + "in": 2, + "want": 9, + "to": 14, + "a": 5, + "someth": 1, + "like": 1, + "this": 1, + "your": 4, + "most": 1, + "popular": 1, + "some": 1, + "best": 1, + "deal": 1, + "new": 1, + "arriv": 1, + "need": 1, + "more": 2, + "help": 4, + "talk": 1, + "agent": 1, + "call": 1, + "custom": 4, + "servic": 3, + "support": 1, + "number": 5, + "contact": 3, + "helplin": 1, + "becom": 1, + "seller": 2, + "status": 6, + "know": 1, + "return": 2, + "credit": 2, + "card": 1, + "accept": 1, + "mastercard": 1, + "pay": 2, + "with": 1, + "cash": 2, + "onli": 1, + "method": 1, + "chang": 4, + "profil": 1, + "inform": 1, + "password": 2, + "phone": 1, + "address": 1, + "reset": 1, + "delet": 2, + "account": 2, + "common": 1, + "reason": 2, + "refund": 11, + "avail": 1, + "the": 1, + "when": 2, + "it": 1, + "even": 1, + "though": 1, + "say": 1, + "receiv": 2, + "thank": 3, + "that": 1, + "lot": 1, + "thx": 1, + "thnks": 1, + "track": 4, + "im": 2, + "okay": 1, + "use": 1, + "voucher": 1 + }, + "isEditing": false, + "className": "BrainNLU", + "classifier": { + "className": "BrainClassifier", + "settings": { + "language": "en", + "useNoneFeature": true, + "config": { + "activation": "leaky-relu", + "hiddenLayers": [], + "iterations": 20000, + "learningRate": 0.7, + "errorThresh": 0.00005, + "momentum": 0.5, + "deltaErrorThresh": 0.000001, + "maxDecimals": 9, + "leakyReluAlpha": 0.08, + "timeout": 120000 + } + }, + "labels": [], + "network": { + "sizes": [ + 137, + 21 + ], + "layers": [ + { + "can": {}, + "i": {}, + "cancel": {}, + "my": {}, + "order": {}, + "whi": {}, + "is": {}, + "how": {}, + "do": {}, + "an": {}, + "payment": {}, + "was": {}, + "process": {}, + "success": {}, + "but": {}, + "did": {}, + "not": {}, + "get": {}, + "ani": {}, + "confirm": {}, + "what": {}, + "should": {}, + "complet": {}, + "no": {}, + "money": {}, + "deduct": {}, + "delay": {}, + "long": {}, + "doe": {}, + "deliveri": {}, + "take": {}, + "ship": {}, + "pleas": {}, + "tell": {}, + "me": {}, + "about": {}, + "goodby": {}, + "bye": {}, + "care": {}, + "see": {}, + "you": {}, + "later": {}, + "for": {}, + "now": {}, + "must": {}, + "go": {}, + "hi": {}, + "howdi": {}, + "greet": {}, + "anyon": {}, + "there": {}, + "good": {}, + "day": {}, + "which": {}, + "item": {}, + "have": {}, + "kind": {}, + "of": {}, + "are": {}, + "sell": {}, + "offer": {}, + "buy": {}, + "am": {}, + "look": {}, + "interest": {}, + "in": {}, + "want": {}, + "to": {}, + "a": {}, + "someth": {}, + "like": {}, + "this": {}, + "your": {}, + "most": {}, + "popular": {}, + "some": {}, + "best": {}, + "deal": {}, + "new": {}, + "arriv": {}, + "need": {}, + "more": {}, + "help": {}, + "talk": {}, + "agent": {}, + "call": {}, + "custom": {}, + "servic": {}, + "support": {}, + "number": {}, + "contact": {}, + "helplin": {}, + "becom": {}, + "seller": {}, + "status": {}, + "know": {}, + "return": {}, + "credit": {}, + "card": {}, + "accept": {}, + "mastercard": {}, + "pay": {}, + "with": {}, + "cash": {}, + "onli": {}, + "method": {}, + "chang": {}, + "profil": {}, + "inform": {}, + "password": {}, + "phone": {}, + "address": {}, + "reset": {}, + "delet": {}, + "account": {}, + "common": {}, + "reason": {}, + "refund": {}, + "avail": {}, + "the": {}, + "when": {}, + "it": {}, + "even": {}, + "though": {}, + "say": {}, + "receiv": {}, + "thank": {}, + "that": {}, + "lot": {}, + "thx": {}, + "thnks": {}, + "track": {}, + "im": {}, + "okay": {}, + "use": {}, + "voucher": {}, + "nonefeature": {} + }, + { + "cancel": { + "bias": -0.930104211, + "weights": { + "can": 0.957473934, + "i": -0.525079012, + "cancel": 10.265582085, + "my": 0.371328503, + "order": 2.088700533, + "whi": 0.763850927, + "is": -0.068361253, + "how": -0.463389397, + "do": 0.687301219, + "an": 0.640718579, + "payment": -0.919768572, + "was": -1.166503429, + "process": -0.919768572, + "success": -0.919768572, + "but": -1.180019498, + "did": -1.157185674, + "not": -1.426754475, + "get": -1.157185674, + "ani": -0.919768572, + "confirm": -1.426754475, + "what": -1.940908074, + "should": -0.919768572, + "money": -0.260250956, + "deduct": -0.260250956, + "delay": -2.570578575, + "long": -0.286226302, + "doe": -0.286226302, + "deliveri": -0.375763714, + "take": -0.286226302, + "ship": -0.120106436, + "pleas": -0.209643885, + "tell": -0.209643885, + "me": -0.209643885, + "about": -0.209643885, + "goodby": -0.029091995, + "bye": -0.025927998, + "see": -0.198298454, + "you": -0.26647839, + "later": -0.027464474, + "for": -0.146737397, + "now": -0.025927998, + "must": -0.210782304, + "go": -0.210782304, + "hi": -0.016967077, + "howdi": -0.016017871, + "greet": -0.015121765, + "anyon": -0.06386508, + "there": -0.06386508, + "good": -0.01155964, + "day": -0.063141055, + "which": -0.021084057, + "item": -0.21557793, + "have": -0.191918015, + "are": -0.047095876, + "buy": -0.408729583, + "am": -0.206254885, + "look": -0.120809413, + "interest": -0.085445479, + "in": -0.256279439, + "want": -0.044856783, + "to": -0.623640776, + "a": -0.035365846, + "someth": -0.001406228, + "like": -0.001406228, + "this": -0.001406228, + "your": -0.051581416, + "talk": -0.556157231, + "agent": -0.556157231, + "call": -0.372072816, + "custom": -0.372072816, + "servic": -0.372072816, + "contact": -0.00603973, + "becom": -0.014387307, + "seller": -0.020427037, + "status": -0.48863253, + "know": -0.029917972, + "return": -0.032117736, + "pay": -0.358795494, + "with": -0.257904977, + "cash": -0.257904977, + "chang": -0.542808354, + "profil": -0.542808354, + "inform": -0.542808354, + "delet": -0.050617501, + "account": -0.050617501, + "refund": -1.975938201, + "track": -1.941594362, + "nonefeature": 2.6e-7 + } + }, + "confirm": { + "bias": -0.572734939, + "weights": { + "can": -0.302591026, + "i": -0.677182376, + "cancel": -0.159523576, + "my": -1.207372069, + "order": 1.71398139, + "whi": -0.600412011, + "is": 0.145629793, + "how": -0.263827533, + "do": -0.132612392, + "an": -0.171640351, + "payment": 0.750604868, + "was": 3.686539412, + "process": 0.117877729, + "success": 0.117877729, + "but": 2.186765194, + "did": -0.469934672, + "not": 2.786321878, + "get": -0.251185477, + "ani": 0.043543316, + "confirm": 4.866500378, + "what": -0.575253546, + "should": 0.117877729, + "complet": 0.888924956, + "no": 0.700297356, + "money": 0.19279699, + "deduct": 1.179962158, + "delay": -0.765385151, + "long": -0.257211715, + "doe": -0.257211715, + "deliveri": -0.211051494, + "take": -0.355303019, + "ship": -0.107694343, + "pleas": -0.061534096, + "tell": -0.061534096, + "me": -0.093213446, + "about": -0.061534096, + "goodby": -0.131685451, + "bye": -0.202042803, + "care": -0.098091289, + "see": -0.118830815, + "you": -0.369320899, + "later": -0.118830815, + "for": -0.154676482, + "now": -0.103951491, + "must": -0.118884884, + "go": -0.118884884, + "hi": -0.10159415, + "howdi": -0.096534215, + "greet": -0.091722503, + "anyon": -0.128030211, + "there": -0.192434385, + "good": -0.07673718, + "day": -0.07673718, + "which": -0.071719304, + "item": -0.149367779, + "have": -0.146053746, + "kind": -0.064404167, + "of": -0.082598262, + "are": -0.352040768, + "sell": -0.060944177, + "offer": -0.043492179, + "buy": -0.088249192, + "am": -0.084193207, + "look": -0.044874575, + "interest": -0.039318629, + "in": -0.039318629, + "want": -0.070641801, + "to": -0.083106577, + "a": -0.03136643, + "someth": -0.004657233, + "like": -0.004657233, + "this": -0.004657233, + "your": -0.287636608, + "most": -0.013244311, + "popular": -0.013244311, + "some": -0.018194089, + "best": -0.018194089, + "deal": -0.018194089, + "new": -0.023532076, + "arriv": -0.023532076, + "need": -0.046341963, + "more": -0.07802131, + "help": -0.083871759, + "talk": -0.012116769, + "agent": -0.012116769, + "call": -0.013533616, + "custom": -0.082597509, + "servic": -0.042812482, + "support": -0.03978502, + "number": -0.103989147, + "contact": -0.006198453, + "helplin": -0.029422821, + "status": -0.572488904, + "credit": -0.204090014, + "method": -0.256198198, + "refund": -1.417948127, + "the": -0.204090014, + "when": -0.26659891, + "it": -0.204090014, + "even": -0.126118794, + "though": -0.126118794, + "say": -0.062508918, + "receiv": -0.987164974, + "track": -1.120849967, + "nonefeature": 2.6e-7 + } + }, + "delay": { + "bias": -2.223512154, + "weights": { + "can": -0.692000508, + "i": -1.675773501, + "cancel": -5.26270771, + "my": 1.892218351, + "order": 2.450906038, + "whi": 2.658858776, + "is": 1.122332335, + "how": -0.362010807, + "do": -0.426054001, + "an": -0.006485965, + "payment": -0.358077705, + "was": -0.358077705, + "process": -0.358077705, + "success": -0.358077705, + "but": -0.358077705, + "did": 2.139068365, + "not": -0.558650017, + "get": 2.339640379, + "ani": -0.363735795, + "confirm": -0.358077705, + "what": -1.800407648, + "should": -0.358077705, + "money": -0.200572416, + "delay": 5.991657257, + "long": -0.142958999, + "doe": -0.142958999, + "deliveri": -3.50396347, + "take": -0.18796438, + "ship": -0.059856821, + "pleas": -0.259056717, + "tell": -0.259056717, + "me": -0.259056717, + "about": -0.259056717, + "goodby": -0.094268851, + "bye": -0.095100984, + "care": -0.045005374, + "see": -0.066799991, + "you": -0.134776279, + "later": -0.066799991, + "for": -1.920385003, + "now": -0.050095607, + "must": -0.051069729, + "go": -0.051069729, + "hi": -0.07874845, + "howdi": -0.074646123, + "greet": -0.070756368, + "anyon": -0.171468496, + "there": -0.198504359, + "good": -0.053611815, + "day": -0.073775604, + "which": -0.029132444, + "item": -0.064197257, + "have": -0.034790568, + "kind": -0.027035886, + "of": -0.036488183, + "are": -0.044517107, + "sell": -0.019569596, + "offer": -0.013616127, + "buy": -0.034369979, + "am": -0.036562871, + "look": -0.018932888, + "interest": -0.017629983, + "in": -0.017629983, + "want": -0.33749038, + "to": -0.345083058, + "a": -0.014635772, + "someth": -0.001962413, + "like": -0.001962413, + "this": -0.001962413, + "your": -0.037645005, + "most": -0.008028925, + "popular": -0.008028925, + "some": -0.009452293, + "best": -0.009452293, + "deal": -0.009452293, + "need": -0.039504211, + "more": -0.039504211, + "help": -0.046586424, + "talk": -0.006485965, + "agent": -0.006485965, + "call": -0.006927921, + "custom": -0.059661351, + "servic": -0.030051192, + "support": -0.029610159, + "number": -0.095319346, + "contact": -0.00818897, + "helplin": -0.027593594, + "status": -1.433053613, + "know": -0.088160224, + "return": -0.088160224, + "chang": -0.139278173, + "profil": -0.066707537, + "inform": -0.066707537, + "password": -0.064918637, + "phone": -0.009016867, + "address": -0.007086353, + "reset": -0.008451225, + "delet": -0.321581721, + "account": -0.321581721, + "common": -0.378452688, + "reason": -1.844274163, + "refund": -0.590106189, + "receiv": -0.200572416, + "track": -0.81316787, + "nonefeature": 2.6e-7 + } + }, + "delivery": { + "bias": -0.700778718, + "weights": { + "can": -0.610703945, + "i": -1.03035748, + "cancel": -0.006151776, + "my": 1.387134075, + "order": -0.409993261, + "whi": -0.006151776, + "is": -0.486358523, + "how": 1.584909439, + "do": -0.589591146, + "an": -0.003932309, + "did": -0.009784149, + "get": -0.009784149, + "ani": -0.01330458, + "what": -0.403264731, + "delay": -1.526154637, + "long": 3.484032393, + "doe": 3.484032393, + "deliveri": 2.239437342, + "take": 2.391818047, + "ship": 2.26478982, + "pleas": 2.536566257, + "tell": 2.536566257, + "me": 1.961959124, + "about": 2.536566257, + "goodby": -0.199656576, + "bye": -0.908763349, + "care": -0.809895456, + "see": -0.13074632, + "you": -0.790201604, + "later": -0.13074632, + "for": -0.515491605, + "now": -0.098867871, + "must": -0.117900781, + "go": -0.117900781, + "hi": -0.144212514, + "howdi": -0.136842534, + "greet": -0.129845858, + "anyon": -0.093649961, + "there": -0.160237893, + "good": -0.102606177, + "day": -0.315322876, + "which": -0.068892926, + "item": -0.153021023, + "have": -0.08219751, + "kind": -0.066587925, + "of": -0.087817602, + "are": -0.322417587, + "sell": -0.04598308, + "offer": -0.031896338, + "buy": -0.080433242, + "am": -0.089072481, + "look": -0.048008692, + "interest": -0.041063789, + "in": -0.041063789, + "want": -0.089900576, + "to": -0.886791527, + "a": -0.392226964, + "someth": -0.005892708, + "like": -0.005892708, + "this": -0.005892708, + "your": -0.25148651, + "most": -0.017540164, + "popular": -0.017540164, + "some": -0.021229673, + "best": -0.021229673, + "deal": -0.021229673, + "need": -0.059132416, + "more": -0.633740664, + "help": -0.633740664, + "talk": -0.003932309, + "agent": -0.003932309, + "call": -0.006976326, + "custom": -0.349959284, + "servic": -0.306066215, + "support": -0.043893099, + "number": -0.084470235, + "contact": -0.41857478, + "helplin": -0.04057714, + "becom": -0.238496244, + "seller": -0.357981175, + "status": -0.365382493, + "know": -0.055654794, + "return": -0.191542387, + "credit": -0.282318592, + "card": -0.282318592, + "pay": -0.122779578, + "chang": -0.333390713, + "profil": -0.333390713, + "inform": -0.333390713, + "delet": -0.350394726, + "account": -0.350394726, + "common": -0.36861515, + "reason": -0.36861515, + "track": -0.220217168, + "nonefeature": 2.6e-7 + } + }, + "greetings.bye": { + "bias": 1.482027045, + "weights": { + "can": -1.783964872, + "i": 0.753705263, + "my": -0.744887352, + "order": -0.772368312, + "is": -0.559961081, + "how": -1.109860539, + "do": -2.02209568, + "an": -0.051306263, + "payment": -0.192733005, + "was": -0.260007441, + "but": -0.192733005, + "did": -0.062434334, + "not": -0.369162798, + "ani": -0.13553302, + "confirm": -0.260007441, + "what": -2.281577587, + "complet": -0.192733005, + "no": -0.377738476, + "money": -0.301888317, + "delay": -1.183904767, + "long": -0.695258796, + "doe": -0.695258796, + "deliveri": -0.597039878, + "take": 1.442578197, + "ship": -0.419695586, + "me": -0.238853157, + "goodby": 10.377142906, + "bye": 6.57363224, + "care": 2.978529453, + "see": 4.61480093, + "you": 0.462690055, + "later": 5.856722832, + "for": 0.792383969, + "now": 3.595104933, + "must": 5.099290371, + "go": 5.099290371, + "hi": -1.5006634, + "howdi": -1.489623785, + "greet": -1.478035927, + "anyon": -0.505021989, + "there": -0.655818582, + "good": -1.093785644, + "day": -0.710998893, + "which": -0.407586753, + "item": -0.558383405, + "have": -1.785049558, + "kind": -0.150796711, + "of": -0.150796711, + "are": -1.016334295, + "sell": -0.32292971, + "offer": -0.242146477, + "buy": -0.630566776, + "am": -1.910566211, + "look": -1.325175047, + "interest": -0.266816765, + "in": -1.508746743, + "want": -0.588621855, + "to": -0.77574563, + "a": -0.382589281, + "someth": -0.140535131, + "like": -0.140535131, + "this": -0.140535131, + "need": -0.269211143, + "more": -0.508064151, + "help": -0.910015345, + "talk": -0.051306263, + "agent": -0.051306263, + "call": -0.054739516, + "custom": -0.470226586, + "servic": -0.252163321, + "support": -0.21806325, + "number": -1.130608201, + "contact": -0.410024941, + "helplin": -0.405885577, + "becom": -0.037336316, + "seller": -0.037336316, + "status": -0.313077956, + "know": -0.079985864, + "return": -0.12807247, + "credit": -0.840692043, + "card": -0.840692043, + "accept": -0.073207565, + "mastercard": -0.073207565, + "pay": -0.07375063, + "with": -0.07375063, + "cash": -0.582578063, + "onli": -0.508827567, + "chang": -0.13867408, + "password": -0.132956266, + "address": -0.068888821, + "reset": -0.063171007, + "delet": -0.40062204, + "account": -0.40062204, + "common": -0.255488276, + "reason": -1.117916465, + "refund": -0.564818978, + "avail": -0.077925235, + "when": -0.172386989, + "even": -0.012618554, + "though": -0.012618554, + "say": -0.172386989, + "receiv": -0.301888317, + "thank": -1.919605136, + "that": -0.04232106, + "thx": -1.385370255, + "thnks": -1.3671031, + "track": -0.705093861, + "im": -0.382787436, + "okay": -0.318574965, + "use": -0.038462117, + "voucher": -0.038462117, + "nonefeature": -1.322396755 + } + }, + "greetings.hello": { + "bias": 2.698667464, + "weights": { + "can": -0.60504806, + "i": -1.917989016, + "cancel": -0.008206869, + "my": -1.192340374, + "order": -1.559731841, + "whi": -0.184929654, + "is": -0.311327934, + "how": -4.098875523, + "do": -1.826153755, + "an": -0.111273386, + "payment": -0.387421727, + "was": -0.387693524, + "but": -0.415208668, + "not": -0.674776733, + "ani": -0.213093489, + "confirm": -0.415480494, + "what": -1.993245363, + "complet": -0.387421727, + "no": -0.696851373, + "money": -0.49778229, + "deduct": -0.027786948, + "delay": -1.586606622, + "deliveri": -1.23436892, + "take": -0.807368636, + "me": -0.462088704, + "goodby": -2.347725868, + "bye": -1.080813527, + "care": -0.771229148, + "see": -0.471309781, + "you": -1.701667905, + "later": -0.471309781, + "for": -1.260156155, + "now": -0.309584171, + "must": -0.360689968, + "go": -0.360689968, + "hi": 9.377306938, + "howdi": 9.369662285, + "greet": 9.281285286, + "anyon": 5.525262833, + "there": 4.409354687, + "good": 3.508930683, + "day": 5.511136532, + "which": -0.317819774, + "item": -1.433729053, + "have": -0.530913234, + "kind": -1.115909338, + "of": -1.127256632, + "are": -1.533455849, + "sell": -0.19289349, + "offer": -0.13181892, + "buy": -0.374793947, + "am": -0.77066499, + "look": -0.362793028, + "interest": -0.372954786, + "in": -0.372954786, + "want": -0.181585655, + "to": -0.673930883, + "a": -0.799342215, + "someth": -0.051081214, + "like": -0.051081214, + "this": -0.051081214, + "your": -3.729150772, + "some": -0.011347306, + "best": -0.011347306, + "deal": -0.011347306, + "new": -0.045455962, + "arriv": -0.045455962, + "need": -0.523759425, + "more": -0.985848129, + "help": -1.878553987, + "talk": -0.111273386, + "agent": -0.111273386, + "call": -0.082341753, + "custom": -1.003163695, + "servic": -0.525216877, + "support": -0.477946907, + "number": -1.86661768, + "contact": -0.251785755, + "helplin": -1.019777536, + "becom": -0.124325402, + "seller": -0.178371236, + "status": -0.901060641, + "return": -0.06684, + "credit": -0.212862298, + "card": -0.036139484, + "accept": -0.09910164, + "mastercard": -0.09910164, + "pay": -0.218224779, + "with": -0.218224779, + "cash": -0.35782811, + "onli": -0.139603361, + "delet": -0.836065888, + "account": -0.836065888, + "common": -0.173662707, + "reason": -0.525900602, + "refund": -1.22274375, + "avail": -0.26659593, + "the": -0.176722825, + "when": -0.198360458, + "it": -0.176722825, + "even": -0.287792295, + "though": -0.287792295, + "say": -0.021637654, + "receiv": -0.469995379, + "thank": -2.654542446, + "that": -0.830826819, + "lot": -0.439385504, + "thx": -2.57408762, + "thnks": -2.540444374, + "track": -1.17539835, + "im": -5.720004559, + "okay": -0.034917634, + "nonefeature": -2.407108068 + } + }, + "items": { + "bias": -0.437643852, + "weights": { + "can": -2.162075996, + "i": 1.371232748, + "my": -2.894840956, + "order": -1.935244799, + "is": -1.39223659, + "how": -3.298545837, + "do": 2.637309074, + "an": -3.243030787, + "payment": -3.296664, + "was": -0.630252481, + "process": -0.630252481, + "success": -0.630252481, + "but": -0.630252481, + "did": -0.630252481, + "not": -0.630252481, + "get": -0.630252481, + "ani": 3.877095461, + "confirm": -0.630252481, + "what": 6.363491058, + "should": -0.630252481, + "delay": -0.99723804, + "deliveri": -0.354783773, + "take": -1.84620595, + "me": -0.395490855, + "bye": -0.972704113, + "see": -1.664452672, + "you": 1.609744668, + "later": -0.09757179, + "for": 2.427061558, + "now": -0.972704113, + "must": -0.461389661, + "go": -0.461389661, + "anyon": -0.008284496, + "there": 1.086342096, + "good": -1.106301188, + "which": 1.632452965, + "item": 2.582840919, + "have": 4.572917461, + "kind": 1.094626427, + "of": 2.456726313, + "are": -0.647481382, + "sell": 2.399790764, + "offer": 2.372802734, + "buy": 7.345085621, + "am": 4.225178719, + "look": 4.849271297, + "interest": 4.395131588, + "in": 2.82825017, + "want": 1.64228189, + "to": -0.126443163, + "a": 2.669211149, + "someth": 0.006806381, + "like": 0.006806381, + "this": 0.006806381, + "your": 0.666430712, + "most": 1.970742226, + "popular": 1.970742226, + "some": 1.36209929, + "best": 1.36209929, + "deal": 1.36209929, + "new": 0.134184629, + "arriv": 0.134184629, + "need": -0.464882344, + "more": -0.860373139, + "help": -1.312635422, + "talk": -1.12804985, + "agent": -1.12804985, + "call": -0.926660955, + "custom": -1.446992874, + "servic": -1.167274237, + "support": -0.279718697, + "number": -1.17300272, + "contact": -0.750090003, + "helplin": -0.349029988, + "becom": -0.34284839, + "seller": -0.492054224, + "status": -1.790418744, + "know": -0.485426009, + "return": -0.485426009, + "credit": -1.84620595, + "card": -1.84620595, + "accept": -2.245224237, + "mastercard": -2.245224237, + "pay": -1.638321996, + "with": -0.395812005, + "cash": -1.388990402, + "onli": -0.993178427, + "method": -2.666410446, + "chang": -0.310148567, + "password": -0.259830445, + "address": -0.127694324, + "reset": -0.077376217, + "delet": -0.0866464, + "account": -0.0866464, + "common": -0.354783773, + "reason": -0.99723804, + "refund": -2.114980936, + "thank": -1.217184901, + "lot": -0.078959547, + "thx": -0.018297104, + "thnks": -0.017425302, + "im": -1.106301188, + "okay": -5.019218922, + "use": -0.772900522, + "voucher": -0.772900522, + "nonefeature": 2.6e-7 + } + }, + "more.contact": { + "bias": 0.106528098, + "weights": { + "can": -0.222838446, + "i": -0.244069114, + "my": -0.665188968, + "order": -0.129259527, + "whi": -0.008058723, + "is": -1.797547579, + "how": -0.202562511, + "do": -0.173062861, + "an": -0.105529279, + "payment": -0.089555241, + "was": -0.031498812, + "but": -0.020652827, + "not": -0.096712969, + "confirm": -0.031498812, + "what": -0.119320318, + "complet": -0.020652827, + "no": -0.080035776, + "money": -0.077808246, + "delay": -0.098963626, + "deliveri": -0.63247782, + "take": -0.113014616, + "pleas": -0.541281581, + "tell": -0.541281581, + "me": 2.815993547, + "about": -0.541281581, + "goodby": -0.109490126, + "bye": -0.028607074, + "care": -0.028607074, + "you": -0.284087241, + "for": -1.108118892, + "hi": -0.107590616, + "howdi": -0.106522925, + "greet": -0.105720267, + "good": -0.105393134, + "day": -0.046644624, + "are": -0.217707217, + "am": -0.010419126, + "want": -0.007608225, + "to": -0.26918152, + "a": -0.06797833, + "your": -0.096509814, + "need": 2.822984695, + "more": 6.180260181, + "help": 3.392209053, + "talk": -0.105529279, + "agent": -0.105529279, + "call": -0.086530082, + "custom": -0.288224876, + "servic": -0.185968488, + "support": -0.102256402, + "number": -1.293886662, + "contact": -1.137471557, + "helplin": -0.04897533, + "becom": -0.06797833, + "seller": -0.06797833, + "status": -0.166405946, + "know": -0.007608225, + "return": -0.048546392, + "credit": -0.092466265, + "card": -0.084407546, + "accept": -0.078062415, + "mastercard": -0.078062415, + "pay": -0.033982389, + "with": -0.03077906, + "cash": -0.094852023, + "onli": -0.064072967, + "method": -0.068902418, + "delet": -0.065881245, + "account": -0.065881245, + "common": -0.010007572, + "reason": -0.017774943, + "refund": -0.214273974, + "avail": -0.069024049, + "the": -0.008058723, + "when": -0.053685665, + "it": -0.008058723, + "even": -0.013755993, + "though": -0.013755993, + "say": -0.045626946, + "receiv": -0.077808246, + "thank": -0.216784522, + "that": -1.697707772, + "thx": -0.133782417, + "thnks": -0.129948378, + "track": -0.067995638, + "im": -0.086355917, + "okay": -0.010419126, + "nonefeature": -0.122527488 + } + }, + "more.help": { + "bias": 0.287461157, + "weights": { + "can": 2.551160336, + "i": -1.00156188, + "cancel": -1.146635413, + "my": -2.971086979, + "order": -1.639477253, + "is": -1.498378158, + "how": 0.422367692, + "do": -1.119657278, + "an": 0.971717596, + "payment": -0.238356426, + "was": -0.055171303, + "but": -0.055171303, + "not": -0.111386381, + "confirm": -0.055171303, + "what": -0.862001479, + "complet": -0.055171303, + "no": -0.055171303, + "money": -0.111386381, + "delay": -0.896346331, + "long": -0.170293182, + "doe": -0.170293182, + "deliveri": -0.45201841, + "take": -0.482170105, + "ship": -0.143822521, + "me": -0.533507586, + "goodby": -0.296472996, + "bye": -0.70013994, + "care": -0.003100271, + "you": -1.173532844, + "for": 1.090561867, + "now": -0.697039664, + "hi": -0.285657436, + "howdi": -0.28342551, + "greet": -0.281080604, + "good": -0.260243177, + "day": -0.344043344, + "item": -1.367340207, + "are": -0.812590301, + "buy": -0.876720309, + "am": -0.166600764, + "look": -0.166600764, + "want": -2.92526865, + "to": 2.803542614, + "a": 1.611396313, + "someth": -0.01458518, + "like": -0.01458518, + "this": -0.01458518, + "your": -0.527228415, + "need": -0.54384023, + "more": -1.077347517, + "help": 1.340989351, + "talk": 3.48569417, + "agent": 3.48569417, + "call": 4.634053707, + "custom": 3.71300292, + "servic": 2.110196352, + "support": 1.602806687, + "number": 6.755122185, + "contact": 3.034481525, + "helplin": 5.247686386, + "becom": 3.035251379, + "seller": 4.337525368, + "status": -2.22090745, + "know": -0.742487848, + "return": -1.810294628, + "credit": -0.308776647, + "card": -0.308776647, + "accept": -0.256953955, + "mastercard": -0.256953955, + "pay": -1.289173245, + "with": -0.828322113, + "cash": -0.994019091, + "onli": -0.165696964, + "method": -0.18318513, + "chang": -1.817986369, + "profil": -0.403455824, + "inform": -0.403455824, + "password": -0.057600718, + "phone": -1.356929898, + "common": -0.333809286, + "reason": -0.804607868, + "refund": -1.500329375, + "avail": -0.021602977, + "receiv": -0.111386381, + "thank": -0.675688624, + "that": -0.340472758, + "lot": -0.403249532, + "thx": -0.403669745, + "thnks": -0.392258108, + "track": -0.082229286, + "im": -0.260243177, + "use": -1.714365721, + "voucher": -1.714365721, + "nonefeature": -0.333226889 + } + }, + "order": { + "bias": -1.003031617, + "weights": { + "can": -0.782767832, + "i": -1.935054421, + "cancel": -0.797382116, + "my": 1.16455543, + "order": 1.60676074, + "whi": -1.771003485, + "is": 0.805561602, + "how": 0.689427972, + "do": -1.045280099, + "an": -0.019100323, + "payment": -0.358163625, + "was": -0.061909087, + "process": -0.060054161, + "success": -0.060054161, + "but": -0.185072556, + "did": -0.48292762, + "not": -0.785127699, + "get": -0.48292762, + "ani": -0.060054161, + "confirm": -0.186927497, + "what": 3.146201372, + "should": -0.060054161, + "no": -1.752007246, + "money": -0.125018373, + "deduct": -0.125018373, + "delay": -0.798744202, + "deliveri": -0.038689077, + "take": -0.143546417, + "pleas": -0.038381249, + "tell": -0.038381249, + "me": -0.038381249, + "about": -0.038381249, + "you": -0.902895808, + "for": -0.000323642, + "there": -0.269603729, + "day": -0.323646814, + "item": -0.350503236, + "kind": -0.269603729, + "of": -0.320687354, + "are": -0.932204306, + "sell": -0.198331267, + "offer": -0.189955279, + "want": -0.799599707, + "to": 1.192768097, + "a": -0.31881848, + "your": -0.734638929, + "most": -0.061799068, + "popular": -0.061799068, + "some": -0.051083583, + "best": -0.051083583, + "deal": -0.051083583, + "help": -0.025044078, + "custom": -0.266436428, + "servic": -0.266436428, + "number": -0.562881052, + "contact": -0.345103174, + "becom": -0.240151808, + "seller": -0.31881848, + "status": 6.770805836, + "know": 2.268250704, + "return": 4.845871925, + "credit": -0.741746545, + "card": -0.143546417, + "accept": -0.119454704, + "mastercard": -0.119454704, + "pay": -0.566202402, + "with": -0.273569733, + "cash": -0.347715974, + "onli": -0.074146189, + "method": -0.298109472, + "chang": -2.125850439, + "profil": -0.361521572, + "inform": -0.361521572, + "password": -1.203168511, + "phone": -0.562881052, + "address": -0.31700331, + "reset": -0.318724126, + "delet": -0.522327602, + "account": -0.522327602, + "common": -0.000182185, + "reason": -0.000323642, + "refund": -2.36930728, + "the": -0.598200142, + "when": -1.1253438, + "it": -0.598200142, + "even": -1.224863768, + "though": -1.224863768, + "say": -0.527143717, + "that": -0.025044078, + "track": -0.88577342, + "nonefeature": 2.6e-7 + } + }, + "payments": { + "bias": -0.228797696, + "weights": { + "can": -2.950469017, + "i": 2.422593594, + "cancel": -0.149429709, + "my": -1.745015264, + "order": -0.841024876, + "whi": -0.19050546, + "is": -0.773816466, + "how": -1.991226792, + "do": 3.143004656, + "an": -0.692349553, + "payment": 4.248058796, + "was": -0.948626637, + "process": -0.385240883, + "success": -0.385240883, + "but": -0.948626637, + "did": -0.385240883, + "not": -0.599747419, + "get": -0.385240883, + "ani": -1.085921049, + "confirm": -0.948626637, + "what": -0.598316669, + "should": -0.385240883, + "complet": -0.563385844, + "no": -0.594406366, + "money": -0.024001189, + "delay": -0.545467436, + "long": -0.137850702, + "doe": -0.137850702, + "deliveri": -0.369148433, + "take": 2.57158494, + "ship": -0.137850702, + "goodby": -0.111002497, + "bye": -1.160627842, + "care": -1.160627842, + "see": -0.393161327, + "you": -0.570030987, + "later": -0.37155214, + "for": -0.56138128, + "must": -1.037672162, + "go": -1.037672162, + "hi": -0.035371438, + "howdi": -0.033704516, + "greet": -0.032114424, + "there": -0.086112894, + "good": -1.45499742, + "day": -0.477643132, + "which": -0.711223364, + "item": -1.891830206, + "have": -1.433512807, + "kind": -0.086112894, + "of": -0.813399434, + "are": 0.323958099, + "sell": -1.904554009, + "offer": -1.755569816, + "buy": -0.363051414, + "am": -1.368916631, + "look": -0.212829143, + "interest": -0.367471993, + "in": -0.389081091, + "want": -0.633245587, + "to": -0.633245587, + "a": -0.536449611, + "someth": -0.006011911, + "like": -0.006011911, + "this": -0.006011911, + "your": 3.440179586, + "most": -0.551574588, + "popular": -0.551574588, + "some": -0.727286577, + "best": -0.727286577, + "deal": -0.727286577, + "new": -0.068054795, + "arriv": -0.068054795, + "help": -0.0947069, + "call": -0.176571086, + "custom": -0.176571086, + "servic": -0.176571086, + "number": -0.175979823, + "status": -0.221526012, + "credit": 3.679559231, + "card": 3.870063543, + "accept": 5.037496567, + "mastercard": 5.037496567, + "pay": 8.33078289, + "with": -0.467353135, + "cash": 5.946444035, + "onli": 6.413795948, + "method": 5.196681023, + "chang": -1.023448586, + "profil": -0.472414941, + "inform": -0.472414941, + "password": -0.367933244, + "phone": -0.175979823, + "address": -0.052774943, + "reset": -0.045654338, + "delet": -0.059039894, + "account": -0.059039894, + "common": -0.172233224, + "reason": -0.348552316, + "refund": -1.185259223, + "avail": -0.220094502, + "the": -0.19050546, + "when": -0.221526012, + "it": -0.19050546, + "say": -0.031020572, + "receiv": -0.024001189, + "thank": -0.961693525, + "that": -0.0947069, + "lot": -0.010961475, + "thx": -0.283866763, + "thnks": -0.272270262, + "track": -0.129636347, + "im": -1.45499742, + "okay": -0.788614452, + "use": -0.525488138, + "voucher": -0.525488138, + "nonefeature": -0.157367215 + } + }, + "personal": { + "bias": -2.371587977, + "weights": { + "can": -1.136502147, + "i": -2.992167234, + "cancel": -0.436704248, + "my": -1.327874184, + "order": -0.765577137, + "whi": -0.207515225, + "is": 2.656564236, + "how": 7.0323596, + "do": 0.069603108, + "an": -0.031143358, + "payment": -0.744351804, + "but": -0.037371621, + "not": -0.244886845, + "ani": -0.560658455, + "confirm": -0.037371621, + "what": -2.530439615, + "no": -0.153277785, + "money": -0.037371621, + "deduct": -0.037371621, + "delay": -0.298492849, + "long": -1.313526511, + "doe": -1.313526511, + "deliveri": -0.830887437, + "take": -1.341659904, + "ship": -0.687159836, + "see": -0.553340852, + "you": 3.404120445, + "later": -0.553340852, + "for": -0.215035141, + "anyon": -0.211387977, + "there": -0.325690329, + "good": -0.902838469, + "day": 3.161861181, + "which": -0.457234174, + "item": -1.108919978, + "have": -1.017892599, + "kind": -0.114302412, + "of": -0.607703209, + "are": 4.373958588, + "sell": -0.381310642, + "offer": -0.290834218, + "want": -0.148671612, + "to": -2.490938902, + "a": -0.914952278, + "your": 2.026182413, + "most": -0.506240129, + "popular": -0.506240129, + "some": -0.493400782, + "best": -0.493400782, + "deal": -0.493400782, + "new": -0.097725488, + "arriv": -0.097725488, + "help": -0.342907906, + "custom": -0.6883955, + "servic": -0.6883955, + "number": -0.020221855, + "contact": -0.880528331, + "becom": -0.56166935, + "seller": -0.753802299, + "status": -1.260861874, + "return": -0.90006882, + "credit": -0.235648572, + "card": -0.028133359, + "accept": -0.598447919, + "mastercard": -0.598447919, + "pay": -1.706993461, + "cash": -2.675266027, + "onli": -2.675266027, + "method": -0.744351804, + "chang": -0.499581784, + "profil": -0.405106723, + "inform": -0.405106723, + "password": -0.097838327, + "phone": -0.020221855, + "reset": -0.023585122, + "delet": -0.085795037, + "account": -0.085795037, + "common": -0.121063091, + "reason": -0.215035141, + "refund": -1.233407378, + "avail": -0.841471195, + "the": -0.207515225, + "when": -0.207515225, + "it": -0.207515225, + "even": -0.153277785, + "though": -0.153277785, + "thank": -1.028309345, + "that": -0.342907906, + "lot": -0.161150008, + "thx": -0.046663761, + "thnks": -0.0442825, + "track": -0.291501313, + "im": -0.294524997, + "nonefeature": -0.013221496 + } + }, + "profile.info": { + "bias": -0.133417878, + "weights": { + "can": -0.563632667, + "i": -0.569288969, + "cancel": -0.314670503, + "my": 4.948005199, + "order": -4.029072762, + "whi": -0.151632518, + "is": -0.273167908, + "how": 0.375885576, + "do": -0.12908034, + "an": -1.65895009, + "payment": -0.000245566, + "was": -0.000245566, + "but": -0.000245566, + "did": -0.681031883, + "not": -0.681031883, + "confirm": -0.000245566, + "complet": -0.000245566, + "no": -0.000245566, + "money": -0.681031883, + "delay": -0.545660317, + "long": -0.10831283, + "doe": -0.10831283, + "deliveri": -1.262806058, + "take": -0.10831283, + "ship": -0.097985856, + "pleas": -0.878604293, + "tell": -0.878604293, + "me": -0.878604293, + "about": -0.878604293, + "you": -0.159900054, + "for": -0.393095344, + "must": -0.020467769, + "go": -0.020467769, + "good": -0.014529927, + "item": -1.65895009, + "are": -0.056416519, + "buy": -0.886527002, + "am": -0.08655525, + "interest": -0.003431663, + "in": -0.003431663, + "want": 0.782398522, + "to": 0.367776811, + "a": -1.042017221, + "help": -0.09526442, + "custom": -0.513054371, + "servic": -0.341889262, + "support": -0.171165138, + "number": 0.814687788, + "contact": -0.311740637, + "helplin": -0.576205075, + "becom": -0.102880821, + "seller": -0.129219338, + "status": -1.899605393, + "know": -1.899605393, + "return": -1.899605393, + "chang": 4.715341568, + "profil": 1.86306572, + "inform": 1.86306572, + "password": 2.89017868, + "phone": 1.618544579, + "address": 2.351923227, + "reset": 4.008370876, + "delet": 3.712605, + "account": 3.712605, + "common": -0.221309572, + "reason": -0.393095344, + "refund": -3.275157213, + "receiv": -0.681031883, + "thank": -0.262397468, + "that": -0.09526442, + "lot": -0.026270946, + "thx": -0.134693563, + "thnks": -0.127963975, + "track": -2.779226542, + "im": -0.014529927, + "okay": -0.083123587, + "nonefeature": -0.005701863 + } + }, + "reason": { + "bias": 0.363332306, + "weights": { + "can": -0.237830117, + "i": -0.882256985, + "my": -2.899031162, + "order": -2.660119295, + "whi": -1.233136296, + "is": -1.443041205, + "how": -1.002487421, + "do": -0.110478155, + "an": -0.260359377, + "payment": -0.094403692, + "was": -0.108380795, + "but": -0.084411815, + "did": -1.326905012, + "not": -0.162350148, + "get": -1.326905012, + "ani": -0.000813878, + "confirm": -0.108380795, + "what": -0.037042294, + "complet": -0.084411815, + "no": -0.184281334, + "money": -0.051783398, + "delay": 7.946032524, + "long": -1.002487421, + "doe": -1.002487421, + "deliveri": 3.656396151, + "take": -1.002487421, + "pleas": -0.338314533, + "tell": -0.338314533, + "me": -0.338314533, + "about": -0.338314533, + "goodby": -0.297736287, + "bye": -0.850351095, + "see": -0.060173843, + "you": -0.223310918, + "later": -0.060173843, + "for": 0.948434591, + "now": -0.850351095, + "hi": -0.287174284, + "howdi": -0.281803876, + "greet": -0.276455998, + "good": -0.285025716, + "day": -0.035564646, + "item": -0.260359377, + "have": -0.000813878, + "of": -0.027050447, + "are": -0.154658437, + "am": -0.534625232, + "look": -0.502456367, + "want": -0.10980168, + "to": -0.10980168, + "a": -0.055892278, + "someth": -0.158683091, + "like": -0.158683091, + "this": -0.158683091, + "your": -0.037042294, + "some": -0.027050447, + "best": -0.027050447, + "deal": -0.027050447, + "new": -0.000274231, + "arriv": -0.000274231, + "help": -0.643726051, + "number": -0.551546395, + "contact": -0.551546395, + "status": -0.186467305, + "credit": -0.086597778, + "accept": -0.000373476, + "mastercard": -0.000373476, + "method": -0.009991848, + "common": -2.569491625, + "reason": 2.852787971, + "refund": -0.702709138, + "avail": -0.117616147, + "the": -0.086597778, + "when": -0.124634154, + "it": -0.086597778, + "even": -0.061833162, + "though": -0.061833162, + "say": -0.038036369, + "receiv": -0.051783398, + "thank": -0.418618649, + "that": -0.092179611, + "lot": -0.055892278, + "thx": -0.344294518, + "thnks": -0.340732872, + "track": -0.076223433, + "im": -0.249461025, + "okay": -0.032168876, + "nonefeature": -0.335134923 + } + }, + "refund.demanding": { + "bias": -1.357716805, + "weights": { + "can": 1.258229852, + "i": 0.253226697, + "cancel": -2.858938217, + "my": 1.126379848, + "order": 1.631664395, + "whi": -0.506008446, + "is": -1.385112524, + "how": -1.028966069, + "do": -0.460352391, + "an": 0.880323112, + "did": -1.617468596, + "not": -3.010694265, + "what": -1.381560087, + "no": -2.195140839, + "money": -2.504686117, + "you": -0.554710984, + "must": -0.030981287, + "go": -0.030981287, + "there": -0.714524806, + "good": -0.008165608, + "which": -0.460352391, + "item": 2.450227022, + "have": -0.460352391, + "kind": -0.714524806, + "of": -0.714524806, + "are": 1.564726472, + "buy": -0.277808398, + "am": -0.319347799, + "want": 0.962794125, + "to": -0.180677801, + "a": -0.90186882, + "your": -0.667035401, + "most": -0.667035401, + "popular": -0.667035401, + "talk": -1.143471599, + "agent": -1.143471599, + "call": -0.261716098, + "custom": -0.261716098, + "servic": -0.261716098, + "number": -0.117016502, + "status": -2.701149225, + "credit": -0.506008446, + "pay": -0.239562705, + "with": -0.239562705, + "cash": -0.239562705, + "chang": -0.547454298, + "profil": -0.064511016, + "inform": -0.064511016, + "password": -0.441843033, + "phone": -0.117016502, + "address": -0.19355832, + "reset": -0.269474506, + "delet": -0.339230269, + "account": -0.339230269, + "refund": 9.392977715, + "avail": 2.946285963, + "the": -0.506008446, + "when": -1.822044492, + "it": -0.506008446, + "even": -0.879104137, + "though": -0.879104137, + "say": -1.316035986, + "receiv": -2.504686117, + "thank": -0.225538954, + "thx": -0.12766391, + "thnks": -0.12098296, + "track": -2.869795322, + "im": -0.008165608, + "okay": -0.319347799, + "use": -0.624060452, + "voucher": -0.624060452, + "nonefeature": 2.6e-7 + } + }, + "refund.status": { + "bias": -0.577006254, + "weights": { + "can": -1.275535226, + "i": -0.964093506, + "cancel": -0.129577592, + "my": -0.212222055, + "order": -2.188269615, + "whi": 0.247978404, + "is": 0.171478286, + "how": -0.693302333, + "do": -0.429312944, + "an": -0.978632629, + "payment": -0.593195975, + "was": -0.60692966, + "process": -0.334855944, + "success": -0.334855944, + "but": -1.189339995, + "did": 1.233000994, + "not": 2.808225155, + "get": -0.334855944, + "ani": -0.334855944, + "confirm": -1.20307374, + "what": -0.583339632, + "should": -0.334855944, + "complet": -0.258340001, + "no": 2.854997158, + "money": 2.77925992, + "deduct": -0.596143544, + "deliveri": -0.011330971, + "take": -0.029870054, + "pleas": -0.011330971, + "tell": -0.011330971, + "me": -0.011330971, + "about": -0.011330971, + "goodby": -0.034834165, + "bye": -0.055330019, + "care": -0.029870054, + "see": -0.004233944, + "you": -0.218650654, + "later": -0.004233944, + "for": -0.025459966, + "now": -0.025459966, + "hi": -0.030127773, + "howdi": -0.028609822, + "greet": -0.027167447, + "anyon": -0.128689289, + "there": -0.128689289, + "good": -0.207964167, + "item": -0.978632629, + "are": -1.748355389, + "am": -0.135213017, + "want": -0.78634572, + "to": -1.084634781, + "a": -0.324194729, + "help": -0.413988143, + "custom": -0.033735305, + "servic": -0.015324207, + "support": -0.018411096, + "number": -0.047861107, + "helplin": -0.014125803, + "status": 2.753764629, + "know": -0.190355778, + "return": -0.488644898, + "credit": 0.377555996, + "refund": 4.081976891, + "avail": -1.748355389, + "the": 0.377555996, + "when": 1.883311272, + "it": 0.377555996, + "even": 1.607581496, + "though": 1.607581496, + "say": 1.505755663, + "receiv": 3.375402689, + "thank": -0.75262332, + "that": -0.413988143, + "lot": -0.296776801, + "thx": -0.179473341, + "thnks": -0.170546561, + "track": -0.80815053, + "im": -0.207964167, + "okay": -0.135213017, + "use": -0.027417935, + "voucher": -0.027417935, + "nonefeature": -0.103483677 + } + }, + "thanks": { + "bias": 2.3507558, + "weights": { + "can": -0.876109481, + "i": -1.820851088, + "cancel": -0.450405657, + "my": -1.703791261, + "order": -1.995606661, + "whi": -0.815269172, + "is": 1.532492638, + "how": -1.835814357, + "do": -1.964483976, + "payment": -0.24102062, + "was": -0.290304393, + "but": -0.615082979, + "not": -0.686882675, + "ani": -0.309037477, + "confirm": -0.664366722, + "what": -0.683308542, + "complet": -0.24102062, + "no": -0.847387552, + "money": -0.48224026, + "deduct": -0.37406233, + "delay": -1.102328658, + "long": -0.330891848, + "doe": -0.330891848, + "deliveri": -1.116755486, + "take": -0.879232347, + "ship": -0.142830655, + "pleas": -0.060637109, + "tell": -0.060637109, + "me": -1.504538417, + "about": -0.060637109, + "goodby": -2.176333427, + "bye": -0.909510374, + "care": -0.548340619, + "see": -1.470972657, + "you": 0.512296677, + "later": -1.470972657, + "for": -1.494753718, + "now": -0.361169845, + "must": -0.261287689, + "go": -0.261287689, + "hi": -2.13279891, + "howdi": -2.104790449, + "greet": -2.0764153, + "anyon": -1.89083159, + "there": -1.926821232, + "good": -1.25579834, + "day": -1.032160163, + "which": -0.312624961, + "item": -0.353116244, + "have": -0.621662199, + "kind": -0.035989624, + "of": -0.080920979, + "are": -1.151642561, + "sell": -0.326269537, + "offer": -0.271616459, + "buy": -0.039591487, + "am": -0.395922124, + "interest": -0.035752855, + "in": -0.035752855, + "want": -0.287532151, + "to": -0.456968278, + "a": -0.844384849, + "your": -0.944375396, + "most": -0.004501672, + "popular": -0.004501672, + "some": -0.044931352, + "best": -0.044931352, + "deal": -0.044931352, + "new": -0.048043016, + "arriv": -0.048043016, + "need": -1.537787437, + "more": -2.981688023, + "help": 2.238121033, + "custom": -0.371635705, + "servic": -0.17571038, + "support": -0.195925325, + "number": -1.877331972, + "contact": -1.129102349, + "helplin": -0.396878481, + "becom": -0.149150863, + "seller": -0.169436112, + "status": -0.761725903, + "credit": -0.15535903, + "accept": -0.433267832, + "mastercard": -0.433267832, + "cash": -0.846970856, + "onli": -0.846970856, + "delet": -0.279890448, + "account": -0.279890448, + "reason": -0.024766834, + "refund": -0.992837071, + "avail": -0.122932822, + "the": -0.15535903, + "when": -0.15535903, + "it": -0.15535903, + "even": -0.606366932, + "though": -0.606366932, + "receiv": -0.108178124, + "thank": 9.813996315, + "that": 6.328623772, + "lot": -0.214661554, + "thx": 9.699923515, + "thnks": 9.593873978, + "track": -0.912350118, + "im": -1.11858058, + "okay": -0.36016947, + "use": -0.420695871, + "voucher": -0.420695871, + "nonefeature": -2.21776557 + } + }, + "track": { + "bias": -0.746210544, + "weights": { + "can": 0.127043054, + "i": -0.210223466, + "cancel": -3.729145527, + "my": -0.267056346, + "order": 2.955234289, + "whi": -1.059894204, + "is": -1.340993404, + "how": 0.486462682, + "do": -1.319559813, + "an": -0.219547361, + "payment": -0.240521729, + "was": -0.761209607, + "process": -0.240521729, + "success": -0.240521729, + "but": -0.292782634, + "did": -0.839619815, + "not": -0.813470483, + "get": -0.839619815, + "ani": -0.240521729, + "confirm": -0.813470483, + "what": -0.469359845, + "should": -0.240521729, + "money": -0.052260935, + "deduct": -0.052260935, + "delay": -0.934813142, + "long": -0.135791063, + "doe": -0.135791063, + "deliveri": -0.091121987, + "take": -0.135791063, + "ship": -0.060032248, + "pleas": -0.015363174, + "tell": -0.015363174, + "me": -0.015363174, + "about": -0.015363174, + "you": -0.107412592, + "good": -0.354141235, + "item": -0.018813383, + "are": -0.107412592, + "buy": -0.419102162, + "am": -0.572401404, + "want": 0.475657165, + "to": 0.044201914, + "a": -1.050123096, + "talk": -0.14663662, + "agent": -0.14663662, + "custom": -0.270215929, + "servic": -0.270215929, + "number": -0.197758526, + "contact": -0.270215929, + "becom": -0.00327834, + "seller": -0.00327834, + "status": -0.681471765, + "know": -0.441309303, + "return": -0.45263347, + "pay": -0.007566913, + "with": -0.007566913, + "cash": -0.007566913, + "chang": -0.775216818, + "profil": -0.09945251, + "inform": -0.09945251, + "password": -0.505166352, + "phone": -0.197758526, + "address": -0.129004195, + "reset": -0.15616481, + "delet": -0.212166041, + "account": -0.212166041, + "refund": -1.741503835, + "track": 10.38419342, + "im": -0.354141235, + "okay": -0.572401404, + "use": -0.627742589, + "voucher": -0.627742589, + "nonefeature": -0.175189987 + } + }, + "user.response": { + "bias": 0.161040842, + "weights": { + "can": -0.839238346, + "i": 0.889569998, + "cancel": -0.622264147, + "my": -0.826192021, + "order": -0.820677221, + "whi": -0.085428931, + "is": -0.251974106, + "how": -1.207494617, + "do": 0.695894659, + "an": -0.106349945, + "payment": -0.275807738, + "was": -0.275807738, + "process": -0.198413044, + "success": -0.198413044, + "but": -0.275807738, + "did": -0.198413044, + "not": -0.198413044, + "get": -0.198413044, + "ani": -0.272808135, + "confirm": -0.275807738, + "what": -0.609856784, + "should": -0.198413044, + "complet": -0.077394709, + "no": -0.078305051, + "long": -0.203081936, + "doe": -0.203081936, + "deliveri": -0.162237525, + "take": -0.408013731, + "ship": -0.085581571, + "pleas": -0.044737179, + "tell": -0.044737179, + "me": -0.064674243, + "about": -0.044737179, + "goodby": -0.417665213, + "bye": -0.295586765, + "care": -0.171893522, + "see": -0.141765833, + "you": -1.128849268, + "later": -0.141765833, + "for": -2.28925848, + "now": -0.123693235, + "must": -0.585501671, + "go": -0.585501671, + "hi": -0.374192685, + "howdi": -0.36229369, + "greet": -0.351596713, + "anyon": -0.166545197, + "there": -0.166545197, + "good": 3.972707033, + "day": -3.952697277, + "which": -0.294237792, + "item": -0.361365378, + "have": -0.368632883, + "are": -0.036236335, + "sell": -0.232038692, + "offer": -0.179405034, + "buy": -0.154865816, + "am": 3.467407465, + "look": -2.165565252, + "interest": -2.214840889, + "in": -2.214840889, + "want": -0.221993446, + "to": -0.221993446, + "a": -0.426978946, + "someth": -0.164576471, + "like": -0.164576471, + "this": -0.164576471, + "need": -0.040609937, + "more": -0.060547002, + "help": -0.060547002, + "call": -0.030289605, + "custom": -0.088894665, + "servic": -0.057310417, + "support": -0.031584244, + "number": -0.103939384, + "helplin": -0.045334321, + "status": -0.000910351, + "credit": -0.033038247, + "card": -0.033038247, + "accept": -0.173968494, + "mastercard": -0.173968494, + "pay": -0.73269099, + "refund": -0.104274318, + "avail": -0.036236335, + "when": -0.000910351, + "say": -0.000910351, + "thank": -0.125409216, + "thx": -0.123471566, + "thnks": -0.121503092, + "im": 7.925405025, + "okay": 7.847812176, + "use": -0.272113293, + "voucher": -0.272113293, + "nonefeature": -0.465342522 + } + }, + "voucher": { + "bias": -0.490182323, + "weights": { + "can": 1.415617347, + "i": 0.452779979, + "cancel": -0.759827554, + "my": -0.603503823, + "order": -0.76677078, + "whi": -0.0003899, + "is": -0.723253727, + "how": -0.633529425, + "do": -0.238678455, + "an": -0.686769664, + "payment": -0.032580439, + "was": -0.032580439, + "but": -0.032580439, + "confirm": -0.032580439, + "what": -0.631405592, + "complet": -0.032580439, + "no": -0.032580439, + "long": -0.007381689, + "doe": -0.007381689, + "deliveri": -0.005234611, + "take": -0.039301526, + "ship": -0.002147079, + "goodby": -0.045438644, + "bye": -0.055411115, + "care": -0.031919837, + "see": -0.24018167, + "you": -0.24018167, + "later": -0.026646167, + "for": -0.136466935, + "now": -0.023491275, + "must": -0.196481481, + "go": -0.196481481, + "hi": -0.028869383, + "howdi": -0.027390188, + "greet": -0.025986236, + "anyon": -0.01539361, + "there": -0.026292838, + "good": -0.022018336, + "day": -0.022018336, + "item": -0.350977182, + "have": -0.213535503, + "kind": -0.010899226, + "of": -0.010899226, + "are": -0.010899226, + "buy": -0.978009224, + "am": -0.195317104, + "look": -0.11297565, + "interest": -0.082341447, + "in": -0.29587695, + "want": -0.571038306, + "to": -1.042594075, + "a": 2.629299879, + "talk": -0.133755445, + "agent": -0.133755445, + "call": -0.46018225, + "custom": -0.46018225, + "servic": -0.46018225, + "contact": -0.150736809, + "becom": -0.187063545, + "seller": -0.337800384, + "pay": -0.459420174, + "with": -0.459420174, + "cash": -0.459420174, + "chang": -0.049668964, + "profil": -0.049668964, + "inform": -0.049668964, + "refund": -0.340077966, + "thank": -0.707470238, + "lot": -0.707470238, + "track": -0.006943134, + "use": 4.24560976, + "voucher": 4.24560976, + "nonefeature": -0.08950004 + } + }, + "None": { + "bias": 0.792101142, + "weights": { + "can": -0.154368654, + "i": -0.519322157, + "cancel": -0.183626041, + "my": -0.439279854, + "order": -0.515051603, + "whi": -0.065024577, + "is": -0.349718392, + "how": -0.332969755, + "do": -0.224955082, + "an": -0.023454566, + "payment": -0.13554424, + "was": -0.164452672, + "process": -0.014886395, + "success": -0.014886395, + "but": -0.119875677, + "did": -0.04507589, + "not": -0.20145829, + "get": -0.04507589, + "ani": -0.060325667, + "confirm": -0.177471057, + "what": -0.198914826, + "should": -0.014886395, + "complet": -0.091970891, + "no": -0.159832403, + "money": -0.128976509, + "deduct": -0.013018386, + "delay": -0.383707851, + "long": -0.133312792, + "doe": -0.133312792, + "deliveri": -0.377474189, + "take": -0.298045665, + "ship": -0.061204772, + "pleas": -0.063173227, + "tell": -0.063173227, + "me": -0.167730987, + "about": -0.063173227, + "goodby": -0.736679673, + "bye": -0.334508628, + "care": -0.164732918, + "see": -0.198920205, + "you": -0.416279495, + "later": -0.198920205, + "for": -0.299671054, + "now": -0.16977571, + "must": -0.164796993, + "go": -0.164796993, + "hi": -0.72275728, + "howdi": -0.712830126, + "greet": -0.702781439, + "anyon": -0.197870195, + "there": -0.231533885, + "good": -0.508143544, + "day": -0.290650219, + "which": -0.032911122, + "item": -0.107973933, + "have": -0.07835038, + "kind": -0.033663709, + "of": -0.072893262, + "are": -0.303141952, + "sell": -0.023499187, + "offer": -0.017549954, + "buy": -0.02900826, + "am": -0.156261802, + "look": -0.014438643, + "interest": -0.081217878, + "in": -0.081217878, + "want": -0.02900826, + "to": -0.20438239, + "a": -0.112851657, + "your": -0.109315671, + "most": -0.041399125, + "popular": -0.041399125, + "some": -0.039229564, + "best": -0.039229564, + "deal": -0.039229564, + "new": -0.004591411, + "arriv": -0.004591411, + "need": -0.16515696, + "more": -0.269714683, + "help": -0.344300508, + "custom": -0.214380324, + "servic": -0.104482912, + "support": -0.109897427, + "number": -0.486834437, + "contact": -0.04906439, + "helplin": -0.27851215, + "becom": -0.049251787, + "seller": -0.076989375, + "status": -0.152553871, + "return": -0.084692314, + "accept": -0.066386238, + "mastercard": -0.066386238, + "pay": -0.030941335, + "with": -0.030941335, + "cash": -0.048725802, + "onli": -0.017784476, + "method": -0.028687021, + "delet": -0.166033581, + "account": -0.166033581, + "common": -0.001323052, + "reason": -0.107822418, + "refund": -0.326197773, + "avail": -0.142378137, + "when": -0.067861542, + "say": -0.067861542, + "receiv": -0.115958124, + "thank": -0.669407487, + "that": -0.066951193, + "lot": -0.006854028, + "thx": -0.654927731, + "thnks": -0.643414497, + "track": -0.210910201, + "im": -0.217493266, + "okay": -0.060605343, + "nonefeature": 10.995412827 + } + } + } + ], + "trainOpts": { + "iterations": 20000, + "errorThresh": 0.00005, + "fixedError": false, + "deltaErrorThresh": 0.000001, + "learningRate": 0.7, + "momentum": 0.5, + "leakyReluAlpha": 0.08, + "maxDecimals": 9, + "log": false + } + } + } + } + } + } + }, + "extraSentences": [ + [ + "en", + "Can I cancel my order?" + ], + [ + "en", + "Why is my order cancelled?" + ], + [ + "en", + "How do I can cancel my order" + ], + [ + "en", + "Can I cancel an order" + ], + [ + "en", + "My payment was processed successfully but I didn't get any order confirmation. What should I do?" + ], + [ + "en", + "Payment was completed but no confirmation" + ], + [ + "en", + "Order was not confirmed" + ], + [ + "en", + "Money deducted but order is not confirmed" + ], + [ + "en", + "Why is my order delayed? " + ], + [ + "en", + "Did my order get delayed?" + ], + [ + "en", + "Why is my order not delivered yet?" + ], + [ + "en", + "When do I get my delivery?" + ], + [ + "en", + "How long does delivery take?" + ], + [ + "en", + "How long does shipping take?" + ], + [ + "en", + "Please Tell me about my delivery" + ], + [ + "en", + "When do I get my delivery?" + ], + [ + "en", + "Why is my order not delivered yet" + ], + [ + "en", + "goodbye" + ], + [ + "en", + "bye take care" + ], + [ + "en", + "see you later" + ], + [ + "en", + "bye for now" + ], + [ + "en", + "i must go" + ], + [ + "en", + "hello" + ], + [ + "en", + "hi" + ], + [ + "en", + "howdy" + ], + [ + "en", + "Greetings" + ], + [ + "en", + "Is anyone there?" + ], + [ + "en", + "Hello" + ], + [ + "en", + "Good day" + ], + [ + "en", + "Which items do you have?" + ], + [ + "en", + "What kinds of items are there?" + ], + [ + "en", + "What do you sell?" + ], + [ + "en", + "What do you offer?" + ], + [ + "en", + "What can I buy?" + ], + [ + "en", + "I'm looking for..." + ], + [ + "en", + "Do you have any..." + ], + [ + "en", + "I'm interested in..." + ], + [ + "en", + "Can I see what you have in..." + ], + [ + "en", + "I want to buy a..." + ], + [ + "en", + "I'm looking for something like this..." + ], + [ + "en", + "What are your most popular items?" + ], + [ + "en", + "What are some of your best deals?" + ], + [ + "en", + "Do you have any new arrivals?" + ], + [ + "en", + "Need more help" + ], + [ + "en", + "Help me more" + ], + [ + "en", + "can I talk to an agent" + ], + [ + "en", + "can I call customer service" + ], + [ + "en", + "customer support number" + ], + [ + "en", + "how to contact customer service" + ], + [ + "en", + "customer service number" + ], + [ + "en", + "contact number for help" + ], + [ + "en", + "helpline number" + ], + [ + "en", + "How to become a seller" + ], + [ + "en", + "How to contact a seller" + ], + [ + "en", + "What is my order status" + ], + [ + "en", + "I want to know my return status" + ], + [ + "en", + "How to return status" + ], + [ + "en", + "Do you take credit cards?" + ], + [ + "en", + "Do you accept Mastercard?" + ], + [ + "en", + "Can I pay with Cash?" + ], + [ + "en", + "Are you cash only?" + ], + [ + "en", + "What are your payment methods?" + ], + [ + "en", + "How do I pay?" + ], + [ + "en", + "How are you?" + ], + [ + "en", + "How are you doing?" + ], + [ + "en", + "How is your day?" + ], + [ + "en", + "How can I change my profile information" + ], + [ + "en", + "I want to change my password" + ], + [ + "en", + "I want to change my phone number" + ], + [ + "en", + "I want to change my address" + ], + [ + "en", + "I want to Reset my password" + ], + [ + "en", + "I want to delete my account" + ], + [ + "en", + "delete my account" + ], + [ + "en", + "Common reasons for delivery delay" + ], + [ + "en", + "common reasons for delivery delay" + ], + [ + "en", + "reasons for delay" + ], + [ + "en", + "delivery delay" + ], + [ + "en", + "Can I refund an item." + ], + [ + "en", + "I want to refund an item" + ], + [ + "en", + "can I refund my order" + ], + [ + "en", + "Are refunds available" + ], + [ + "en", + "Why is the status Refunded when it's not credited?" + ], + [ + "en", + "No refund even though status is refunded" + ], + [ + "en", + "No refund when status says refunded" + ], + [ + "en", + "I did not receive my refund money" + ], + [ + "en", + "Refund money not received" + ], + [ + "en", + "Thanks" + ], + [ + "en", + "Thank you" + ], + [ + "en", + "That's helpful" + ], + [ + "en", + "Thank's a lot!" + ], + [ + "en", + "thx" + ], + [ + "en", + "thnks" + ], + [ + "en", + "How can I track my order" + ], + [ + "en", + "I want to track my order" + ], + [ + "en", + "Can I track my order" + ], + [ + "en", + "Track order" + ], + [ + "en", + "I'm good" + ], + [ + "en", + "Im good" + ], + [ + "en", + "Im doing good" + ], + [ + "en", + "I am good" + ], + [ + "en", + "I am okay" + ], + [ + "en", + "How to use a voucher?" + ], + [ + "en", + "Can I use a voucher?" + ], + [ + "en", + "How to use a voucher?" + ] + ] + }, + "nerManager": { + "settings": {}, + "threshold": 0.8, + "builtins": [ + "Number", + "Ordinal", + "Percentage", + "Age", + "Currency", + "Dimension", + "Temperature", + "DateTime", + "PhoneNumber", + "IpAddress", + "Boolean", + "Email", + "Hashtag", + "URL" + ], + "namedEntities": {} + }, + "slotManager": {}, + "responses": { + "en": { + "cancel": [ + { + "response": "Order can only be cancelled within 7 days of placement. Digital goods do not qualify for refund." + }, + { + "response": "Visit my orders page to check status of specific orders." + } + ], + "confirm": [ + { + "response": "knight sends you an Email & SMS upon confirmation of your order. If it requires manual confirmation, our team will contact you within 24 hours after order placement. Delay in confirmation SMS may occur due to network error, you may receive it with a delay." + }, + { + "response": "It takes upto 24 hours for confirmation, please bear with us! Type Need more help for more assistance. " + } + ], + "delay": [ + { + "response": "We are really sorry if there has been a delay in your order! If your order is within delivery period, We recommend you to please wait during this period since our Delivery Heroes make 3 attempts to deliver your order!" + }, + { + "response": "If your order is past up to 3 days after delivery period, There may be logistic issues causing a delay in delivery. Please type 'Common reasons for delivery delay' to know more about this." + }, + { + "response": "We appreciate if you could wait for your items as most orders are delivered successfully within this period." + }, + { + "response": "If your order is past more than 3 days, Since there may be unexpected issues causing delivery delays, you can click on 'Need more Help' for further assistance." + } + ], + "delivery": [ + { + "response": "Delivery takes 2-4 days. Please bear with us!" + }, + { + "response": "Shipping takes 2-4 days. Please bear with us!" + } + ], + "greetings.bye": [ + { + "response": "see you soon!" + }, + { + "response": "Till next time" + }, + { + "response": "bye bye" + }, + { + "response": "have a great day" + }, + { + "response": "See you later, thanks for visiting. Hope I was able to help!" + }, + { + "response": "Have a nice day. Hope I was able to help!" + } + ], + "greetings.hello": [ + { + "response": "Hey :-) My name is knight!" + }, + { + "response": "Hello, thanks for visiting. My name is knight!" + }, + { + "response": "Hi there, My name is knight!. What can I do for you?" + }, + { + "response": "Hi there, My name is knight! How can I help?" + } + ], + "items": [ + { + "response": "Search your preference in our flagship store's search bar to see all available products. " + } + ], + "more.contact": [ + { + "response": "Absolutely! How can I help you today? Here are some options based on your inquiry:" + }, + { + "response": "Sure, let me know what you need help with. Here are a few things I can assist you with:" + } + ], + "more.help": [ + { + "response": "Contact us for further information here: Phone: +250 780 000 000. Timings are from 09:00 AM to 05:00 PM from Monday to Saturday." + }, + { + "response": "For immediate assistance, you can contact our customer service team at +250 780 000 000. Our hours are from 09:00 AM to 05:00 PM, Monday to Saturday." + } + ], + "order": [ + { + "response": "Please visit the My Orders page for a list of your confirmed orders." + } + ], + "payments": [ + { + "response": "We accept VISA and Mastercard" + } + ], + "personal": [ + { + "response": "I'm good, all's good, thanks. How about you?" + } + ], + "profile.info": [ + { + "response": "You can easily add or change your account details by following the steps below: Step 1: Click on 'Account', Step 2: Click on 'Manage my account' from the icon, Step 3: You can change or edit your name, address, email address, mobile number, etc., Step 4: Fill in the required details, and click on Save. Note: You can also change your delivery address from the Checkout page before proceeding to pay." + } + ], + "reason": [ + { + "response": "Reasons include Seller Sourcing Issues, Courier Issues, Cross Border shipment delay, Wrong Address or Phone Number, and Unavailability of Customer. " + } + ], + "refund.demanding": [ + { + "response": "Refund can only be issued within 7 days of placement. Digital goods do not qualify for refund." + }, + { + "response": "Visit my orders page to check for specific orders." + } + ], + "refund.status": [ + { + "response": "Please be patient as refunds take upto 30 days to receive into bank. " + } + ], + "thanks": [ + { + "response": "Happy to help!" + }, + { + "response": "Any time!" + }, + { + "response": "My pleasure" + } + ], + "track": [ + { + "response": "Visit the order page, click on the specific order, select 'track my order', and check the status" + } + ], + "user.response": [ + { + "response": "Great to hear you are doing good." + } + ], + "voucher": [ + { + "response": "You can add a voucher by clicking on My Cart > Check Out > Enter Voucher Code > APPLY. " + } + ] + } + }, + "actions": {}, + "utteranceDict": { + "?": "help" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 8a90cc1..06430f2 100644 --- a/package.json +++ b/package.json @@ -20,28 +20,38 @@ "dependencies": { "@types/express-winston": "^4.0.0", "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", "@types/nodemailer": "^6.4.14", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "axios": "^1.6.8", "bcrypt": "^5.1.1", "class-validator": "^0.14.1", + "cloudinary": "^2.2.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-session": "^1.18.0", "express-winston": "^4.2.0", "highlight.js": "^11.9.0", + "joi": "^17.13.1", "jsend": "^1.1.0", "jsonwebtoken": "^9.0.2", "mailgen": "^2.0.28", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "node-nlp": "^4.27.0", "nodemailer": "^6.9.13", "nodemon": "^3.1.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.11.5", "reflect-metadata": "^0.2.2", + "socket.io": "^4.7.5", "source-map-support": "^0.5.21", + "stripe": "^15.8.0", "superagent": "^9.0.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.0", @@ -62,13 +72,18 @@ "@types/eslint": "^8.56.10", "@types/eslint__js": "^8.42.3", "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.12", + "@types/joi": "^17.2.3", "@types/jsend": "^1.0.32", "@types/jsonwebtoken": "^9.0.6", + "@types/mocha": "^10.0.6", "@types/morgan": "^1.9.9", "@types/node": "^20.12.7", "@types/nodemailer": "^6.4.15", + "@types/passport-google-oauth20": "^2.0.16", "@types/reflect-metadata": "^0.1.0", + "@types/socket.io": "^3.0.2", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", "@types/winston": "^2.4.4", diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts new file mode 100644 index 0000000..8228fa6 --- /dev/null +++ b/src/@types/index.d.ts @@ -0,0 +1 @@ +declare module 'node-nlp'; diff --git a/src/__test__/auth.test.ts b/src/__test__/auth.test.ts new file mode 100644 index 0000000..179a736 --- /dev/null +++ b/src/__test__/auth.test.ts @@ -0,0 +1,154 @@ +import request from 'supertest'; +import express, { Request, Response } from 'express'; +import { + userVerificationService, + userRegistrationService, + userLoginService, + userEnableTwoFactorAuth, + userDisableTwoFactorAuth, + userValidateOTP, + userResendOtpService, + logoutService, +} from '../services'; +import { userPasswordResetService } from '../services/userServices/userPasswordResetService'; +import { sendPasswordResetLinkService } from '../services/userServices/sendResetPasswordLinkService'; +import { activateUserService } from '../services/updateUserStatus/activateUserService'; +import { deactivateUserService } from '../services/updateUserStatus/deactivateUserService'; +import { userProfileUpdateServices } from '../services/userServices/userProfileUpdateServices'; +import { activateUser, disable2FA, disactivateUser, enable2FA, login, logout, resendOTP, sampleAPI, sendPasswordResetLink, userPasswordReset, userProfileUpdate, userRegistration, userVerification, verifyOTP } from '../controllers'; + +// Mock the services +jest.mock('../services', () => ({ + userVerificationService: jest.fn(), + userRegistrationService: jest.fn(), + userLoginService: jest.fn(), + userEnableTwoFactorAuth: jest.fn(), + userDisableTwoFactorAuth: jest.fn(), + userValidateOTP: jest.fn(), + userResendOtpService: jest.fn(), + logoutService: jest.fn(), +})); + +jest.mock('../services/userServices/userPasswordResetService', () => ({ + userPasswordResetService: jest.fn(), +})); + +jest.mock('../services/userServices/sendResetPasswordLinkService', () => ({ + sendPasswordResetLinkService: jest.fn(), +})); + +jest.mock('../services/updateUserStatus/activateUserService', () => ({ + activateUserService: jest.fn(), +})); + +jest.mock('../services/updateUserStatus/deactivateUserService', () => ({ + deactivateUserService: jest.fn(), +})); + +jest.mock('../services/userServices/userProfileUpdateServices', () => ({ + userProfileUpdateServices: jest.fn(), +})); + +const app = express(); +app.use(express.json()); + +app.post('/register', userRegistration); +app.post('/verify', userVerification); +app.post('/login', login); +app.post('/enable-2fa', enable2FA); +app.post('/disable-2fa', disable2FA); +app.post('/verify-otp', verifyOTP); +app.post('/resend-otp', resendOTP); +app.get('/sample', sampleAPI); +app.post('/reset-password', userPasswordReset); +app.post('/send-reset-link', sendPasswordResetLink); +app.post('/activate', activateUser); +app.post('/deactivate', disactivateUser); +app.post('/logout', logout); +app.put('/update-profile', userProfileUpdate); + +describe('User Controller', () => { + it('should call userRegistrationService on /register', async () => { + (userRegistrationService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(201).send()); + await request(app).post('/register').send({}); + expect(userRegistrationService).toHaveBeenCalled(); + }); + + it('should call userVerificationService on /verify', async () => { + (userVerificationService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/verify').send({}); + expect(userVerificationService).toHaveBeenCalled(); + }); + + it('should call userLoginService on /login', async () => { + (userLoginService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/login').send({}); + expect(userLoginService).toHaveBeenCalled(); + }); + + it('should call userEnableTwoFactorAuth on /enable-2fa', async () => { + (userEnableTwoFactorAuth as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/enable-2fa').send({}); + expect(userEnableTwoFactorAuth).toHaveBeenCalled(); + }); + + it('should call userDisableTwoFactorAuth on /disable-2fa', async () => { + (userDisableTwoFactorAuth as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/disable-2fa').send({}); + expect(userDisableTwoFactorAuth).toHaveBeenCalled(); + }); + + it('should call userValidateOTP on /verify-otp', async () => { + (userValidateOTP as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/verify-otp').send({}); + expect(userValidateOTP).toHaveBeenCalled(); + }); + + it('should call userResendOtpService on /resend-otp', async () => { + (userResendOtpService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/resend-otp').send({}); + expect(userResendOtpService).toHaveBeenCalled(); + }); + + it('should return 200 on /sample', async () => { + const response = await request(app).get('/sample'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'Token is valid' }); + }); + + it('should call userPasswordResetService on /reset-password', async () => { + (userPasswordResetService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/reset-password').send({}); + expect(userPasswordResetService).toHaveBeenCalled(); + }); + + it('should call sendPasswordResetLinkService on /send-reset-link', async () => { + (sendPasswordResetLinkService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/send-reset-link').send({}); + expect(sendPasswordResetLinkService).toHaveBeenCalled(); + }); + + it('should call activateUserService on /activate', async () => { + (activateUserService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/activate').send({}); + expect(activateUserService).toHaveBeenCalled(); + }); + + it('should call deactivateUserService on /deactivate', async () => { + (deactivateUserService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/deactivate').send({}); + expect(deactivateUserService).toHaveBeenCalled(); + }); + + it('should call logoutService on /logout', async () => { + (logoutService as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).post('/logout').send({}); + expect(logoutService).toHaveBeenCalled(); + }); + + it('should call userProfileUpdateServices on /update-profile', async () => { + (userProfileUpdateServices as jest.Mock).mockImplementationOnce((req: Request, res: Response) => res.status(200).send()); + await request(app).put('/update-profile').send({}); + expect(userProfileUpdateServices).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/__test__/cart.test.ts b/src/__test__/cart.test.ts new file mode 100644 index 0000000..81a1412 --- /dev/null +++ b/src/__test__/cart.test.ts @@ -0,0 +1,858 @@ + +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { v4 as uuid } from 'uuid'; +import { User, UserInterface } from '../entities/User'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { Cart } from '../entities/Cart'; +import { CartItem } from '../entities/CartItem'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const buyer1Id = uuid(); +const buyer2Id = uuid(); +const buyer3Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const catId = uuid(); +const cart1Id = uuid(); +const cartItemId = uuid(); +const sampleCartId = uuid(); +const sampleCartItemId = uuid(); +const samplecartItem3Id = uuid(); +const feedbackID = uuid(); +const feedbackID2 = uuid(); +const sampleAdminId = uuid(); + +let returnedCartId: string; + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +if (!process.env.TEST_USER_EMAIL || !process.env.TEST_USER_PASS) throw new Error('TEST_USER_PASS or TEST_USER_EMAIL not set in .env'); + +const sampleAdmin: UserInterface = { + id: vendor1Id, + firstName: 'vendor1', + lastName: 'user', + email: process.env.TEST_USER_EMAIL, + password: process.env.TEST_USER_PASS, + userType: 'Vendor', + gender: 'Male', + phoneNumber: '10026380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'ADMIN', +}; + + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendo111@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '11126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'manger@gmail.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '12116380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleBuyer2: UserInterface = { + id: buyer2Id, + firstName: 'buyer1', + lastName: 'user', + email: 'elijahladdiedv@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '12116380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const sampleBuyer3: UserInterface = { + id: buyer3Id, + firstName: 'buyer1', + lastName: 'user', + email: 'elhladdiedv@example.com', + password: 'password', + userType: 'Admin', + gender: 'Male', + phoneNumber: '121163800', + photoUrl: 'https://example.com/photo.jpg', + role: 'ADMIN', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleProduct2 = { + id: product2Id, + name: 'test product2', + description: 'amazing product2', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleCart1 = { + id: cart1Id, + user: sampleBuyer1, + totalAmount: 200, +}; + +const sampleCart2 = { + id: sampleCartId, + totalAmount: 200, +}; + +const sampleCartItem1 = { + id: cartItemId, + product: sampleProduct1, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const sampleCartItem2 = { + id: sampleCartItemId, + product: sampleProduct2, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const sampleCartItem3 = { + id: samplecartItem3Id, + product: sampleProduct2, + cart: sampleCart2, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const bodyTosend = { + productId: product1Id, + quantity: 2, +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1 }); + await userRepository?.save({ ...sampleBuyer1 }); + await userRepository?.save({ ...sampleBuyer2 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); + await productRepository?.save({ ...sampleProduct2 }); + + const cartRepository = connection?.getRepository(Cart); + await cartRepository?.save({ ...sampleCart1 }); + await cartRepository?.save({ ...sampleCart2 }); + + const cartItemRepository = connection?.getRepository(CartItem); + await cartItemRepository?.save({ ...sampleCartItem1 }); + await cartItemRepository?.save({ ...sampleCartItem2 }); + await cartItemRepository?.save({ ...sampleCartItem3 }); +}); + +afterAll(async () => { + await cleanDatabase(); + + server.close(); +}); + +describe('Cart| Order management for guest/buyer', () => { + describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 60000); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .post(`/product/`) + .field('name', 'test-product-images') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should not create new product it already exist', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(409); + }); + + it('should not create new product, if there are missing field data', async () => { + const response = await request(app) + .post('/product') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('should not create new product, images are not at least more than 1', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test-product-image') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Adding product to cart on guest/buyer', () => { + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as guest', async () => { + const response = await request(app).post(`/cart`).send(bodyTosend); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + + returnedCartId = response.body.data.cart.id; + }); + + it('should add second product to cart as guest', async () => { + const response = await request(app) + .post(`/cart`) + .set('Cookie', [`cartId=${returnedCartId}`]) + .send({ + productId: product1Id, + quantity: 3, + }); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 400 for incorrect Id syntax (IDs not in uuid form), when add product to cart', async () => { + const response = await request(app) + .post(`/cart`) + .set('Cookie', [`cartId=dfgdsf`]) + .send({ + productId: product1Id, + quantity: 3, + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 if you do not send proper request body', async () => { + const response = await request(app).post(`/cart`); + + expect(response.status).toBe(400); + }); + + it('should not add product to cart if product does not exist', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: uuid(), quantity: 2 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found, try again.'); + }); + + it('should not add product to cart if quantity is less than 1', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product1Id, quantity: 0 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Quantity must be greater than 0'); + }); + + it('should change quantity of product in cart if it is already there', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product1Id, quantity: 3 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + }); + + describe('Getting cart items', () => { + it('should get cart items of authenticated user', async () => { + const response = await request(app) + .get('/cart') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart retrieved successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get Empty cart items of authenticated user', async () => { + const response = await request(app) + .get('/cart') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get Empty cart items of guest user', async () => { + const response = await request(app).get('/cart'); + + expect(response.status).toBe(200); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user', async () => { + const response = await request(app) + .get('/cart') + .set('Cookie', [`cartId=${returnedCartId}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart retrieved successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .get('/cart') + .set('Cookie', [`cartId=${uuid()}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + + it('should return 400 for incorrect Id syntax (IDs not in uuid form), when getting cart', async () => { + const response = await request(app) + .get(`/cart`) + .set('Cookie', [`cartId=dfgdsf`]); + + expect(response.status).toBe(400); + }); + }); + + describe('Order management tests', () => { + let orderId: any; + let productId: any; + let feedbackId: any; + let feedback2Id: any; + + describe('Create order', () => { + it('should return 201 when user is found', async () => { + const response = await request(app) + .post('/product/orders') + .send({ + address: { + country: 'Test Country', + city: 'Test City', + street: 'Test Street', + }, + }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + }); + + it('should return orders for the buyer', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + orderId = response.body.data.orders[0]?.id; + productId = response.body.data.orders[0]?.orderItems[0]?.product?.id; + }); + + + it('should get single order', async () => { + const response = await request(app) + .get(`/product/client/orders/${orderId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + }); + + it('should not return data for single order, if order doesn\'t exist', async () => { + const response = await request(app) + .get(`/product/client/orders/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + }); + + it('should not return data for single order, for an incorrect id syntax', async () => { + const response = await request(app) + .get(`/product/client/orders/incorrectId`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + }); + + it('should return 404 if the buyer has no orders', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + expect(response.status).toBe(404); + expect(response.body.message).toBeUndefined; + }); + + it('should return transaction history for the buyer', async () => { + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Transaction history retrieved successfully'); + }); + + it('should return 400 when user is not AUTHORIZED', async () => { + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ''`); + expect(response.status).toBe(403); + }); + }); + + describe('Update order', () => { + it('should update order status successfully', async () => { + const response = await request(app) + .put(`/product/client/orders/${orderId}`) + .send({ orderStatus: 'completed' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + }); + describe('Add feedback to the product with order', () => { + it('should create new feedback to the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Well this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedbackId = response.body.data.id + }); + it('should create new feedback to the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Murigalike this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedback2Id = response.body.data.id + }); + it('should updated existing feedback successfully', async () => { + const response = await request(app) + .put(`/feedback/update/${feedbackId}`,) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + it('should remove recorded feedback', async () => { + const response = await request(app) + .delete(`/feedback/delete/${feedbackId}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + it('should remove recorderd feedback as admin ', async () => { + const response = await request(app) + .delete(`/feedback/admin/delete/${feedback2Id}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer3Id, sampleBuyer3.email)}`); + expect(response.status).toBe(401); + }); + it('should remove recorder feedback as admin ', async () => { + const response = await request(app) + .delete(`/feedback/admin/delete/${feedback2Id}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer3Id, sampleBuyer3.email)}`); + expect(response.status).toBe(401); + }); + + it('should return 404 if feedback not found', async () => { + const response = await request(app) + .post(`/feedback/admin/delete/${feedbackID}`) + .set('Authorization', `Bearer ${getAccessToken(sampleAdminId, sampleAdmin.email)}`); + expect(response.status).toBe(404); + }) + + it('should handle server error by returning 500 ', async () => { + const response = await request(app) + .delete(`/feedback/admin/delete/ghkjh - *****`) + .set('Authorization', `Bearer ${getAccessToken(sampleAdminId, sampleAdmin.email)}`); + expect(response.status).toBe(401); + }); + }); + + describe('Feedback API', () => { + + describe('Add feedback to the product with order', () => { + it('should create new feedback for the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Well this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedbackId = response.body.data.id; + }); + + it('should create another feedback for the ordered product', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId, comment: 'Murigalike this product looks so fantastic' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(201); + feedback2Id = response.body.data.id; + }); + + it('should fail to create feedback with missing orderId', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ comment: 'Missing orderId' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(404); + }); + + it('should fail to create feedback with missing comment', async () => { + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + + it('should fail to create feedback with invalid productId', async () => { + const response = await request(app) + .post(`/feedback/invalidProductId/new`) + .send({ orderId, comment: 'Invalid productId' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + }); + + describe('Update feedback', () => { + it('should update existing feedback successfully', async () => { + const response = await request(app) + .put(`/feedback/update/${feedbackId}`) + .send({ orderId, comment: 'Well this product looks so lovely' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + + it('should fail to update feedback with invalid feedbackId', async () => { + const response = await request(app) + .put(`/feedback/update/invalidFeedbackId`) + .send({ orderId, comment: 'Invalid feedbackId' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + + it('should fail to update feedback without authorization', async () => { + const response = await request(app) + .put(`/feedback/update/${feedbackId}`) + .send({ orderId, comment: 'Unauthorized update' }); + expect(response.status).toBe(401); + }); + }); + + describe('Delete feedback', () => { + it('should remove recorded feedback', async () => { + const response = await request(app) + .delete(`/feedback/delete/${feedbackId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + + it('should not allow a different user (admin) to remove feedback', async () => { + const response = await request(app) + .delete(`/feedback/admin/delete/${feedback2Id}`) + .set('Authorization', `Bearer ${getAccessToken(buyer3Id, sampleBuyer3.email)}`); + expect(response.status).toBe(401); + }); + + it('should fail to delete feedback with invalid feedbackId', async () => { + const response = await request(app) + .delete(`/feedback/delete/invalidFeedbackId`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + + it('should fail to delete feedback without authorization', async () => { + const response = await request(app) + .delete(`/feedback/delete/${feedback2Id}`); + expect(response.status).toBe(401); + }); + }); + + describe('Edge Cases', () => { + it('should not allow creating feedback for a product not in the order', async () => { + const invalidOrderId = 999; // Assuming an invalid orderId + const response = await request(app) + .post(`/feedback/${productId}/new`) + .send({ orderId: invalidOrderId, comment: 'Invalid orderId' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + + it('should fail to update feedback with a comment that is too long', async () => { + const longComment = 'a'.repeat(1001); // Assuming max length is 1000 + const response = await request(app) + .put(`/feedback/update/${feedback2Id}`) + .send({ orderId, comment: longComment }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + }); + }); + }); + + }); + + describe('Deleting product from cart', () => { + it('should return 400 if product id is not provided', async () => { + const response = await request(app) + .delete(`/cart/`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + }); + + it('should return 404 if product does not exist in cart', async () => { + const response = await request(app) + .delete(`/cart/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Cart item not found'); + }); + + it('should return 401 if you try to delete item not in your cart', async () => { + const response = await request(app) + .delete(`/cart/${cartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(401); + expect(response.body.message).toBe('You are not authorized to perform this action'); + }); + + it('should delete product from cart', async () => { + const response = await request(app) + .delete(`/cart/${sampleCartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Product removed from cart successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should delete product from cart', async () => { + const response = await request(app) + .delete(`/cart/${cartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('cart removed successfully'); + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product2Id, quantity: 2 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 404 if product does not exist in guest cart', async () => { + const response = await request(app).delete(`/cart/${uuid()}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Cart item not found'); + }); + + it('should return 404 if product does not exist in guest cart', async () => { + const response = await request(app).delete(`/cart/${samplecartItem3Id}`); + + expect(response.status).toBe(200); + }); + }); + + describe('Clearing cart', () => { + it('should return 200 as authenticated buyer does not have a cart', async () => { + const response = await request(app) + .delete(`/cart`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should clear cart as authenticated buyer', async () => { + const response = await request(app) + .delete(`/cart`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart cleared successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 200 as guest does not have a cart', async () => { + const response = await request(app).delete(`/cart`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .get('/cart') + .set('Cookie', [`cartId=${uuid()}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should delete cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .delete('/cart') + .set('Cookie', [`cartId=${sampleCartId}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + }); +}); \ No newline at end of file diff --git a/src/__test__/chatBot.test.ts b/src/__test__/chatBot.test.ts new file mode 100644 index 0000000..e588ff6 --- /dev/null +++ b/src/__test__/chatBot.test.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection} from 'typeorm'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + await createConnection(); +}); + +jest.setTimeout(20000); +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + + +describe('POST /chat', () => { + it('should respond with a successful message for a valid user query', async () => { + const userMessage = 'What kind of items do you have?'; + const response = await request(app) + .post('/chat') + .send({ message: userMessage }) + .expect(200) + .expect('Content-Type', /json/); + + expect(response.body).toHaveProperty('message'); + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }); + it('should respond with an error for an empty user message', async () => { + const emptyMessageReq = { body: { message: '' } }; + const response = await request(app) + .post('/chat') + .send(emptyMessageReq) + + expect(response.status).toBe(400); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('No user message'); + + }); + it('should respond with an error for an empty user message', async () => { + const userMessage = 'dojdojdodjojoqdj'; + const response = await request(app) + .post('/chat') + .send({ message: userMessage }) + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.message).toBe('Sorry, I am not sure what you mean. Can you rephrase?'); + + + }); + +}); diff --git a/src/__test__/coupon.test.ts b/src/__test__/coupon.test.ts new file mode 100644 index 0000000..79eda0f --- /dev/null +++ b/src/__test__/coupon.test.ts @@ -0,0 +1,571 @@ + +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { Coupon } from '../entities/coupon'; +import { CartItem } from '../entities/CartItem'; +import { Cart } from '../entities/Cart'; +import { Product } from '../entities/Product'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const cart1Id = uuid(); +const cartItemId = uuid(); +const buyer1Id = uuid(); +const buyer2Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const vendor2Id = uuid(); +const couponCode = 'DISCOUNT20'; +const couponCode1 = 'DISCOUNT10'; +const couponCode2 = 'DISCOUNT99'; +const couponCode3 = 'DISCOUNT22'; +const expiredCouponCode = 'EXPIRED'; +const finishedCouponCode = 'FINISHED'; +const moneyCouponCode = 'MONEY'; +const invalidCouponCode = 'INVALIDCODE'; + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'Vendor', + lastName: 'User', + email: 'secondendor@example.com', + password: 'password123', + userType: 'Vendor', + gender: 'Male', + verified: true, + phoneNumber: '98000867890', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'Vendor', + lastName: 'User', + email: 'vendor@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '1234567890', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const buyerNoCart: UserInterface = { + id: buyer2Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyr122@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '159380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'Test Product'; +sampleProduct1.description = 'Amazing product'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor1 as User; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'Test 2 Product'; +sampleProduct2.description = 'Amazing product 2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct2.newPrice = 200; +sampleProduct2.quantity = 10; +sampleProduct2.vendor = sampleVendor1 as User; + +const sampleCoupon = new Coupon(); +sampleCoupon.code = couponCode; +sampleCoupon.discountRate = 20; +sampleCoupon.expirationDate = new Date('2025-01-01'); +sampleCoupon.maxUsageLimit = 100; +sampleCoupon.discountType = 'percentage'; +sampleCoupon.product = sampleProduct1; +sampleCoupon.vendor = sampleVendor1 as User; + +const sampleCoupon1 = new Coupon(); +sampleCoupon1.code = couponCode1; +sampleCoupon1.discountRate = 20; +sampleCoupon1.expirationDate = new Date('2025-01-01'); +sampleCoupon1.maxUsageLimit = 100; +sampleCoupon1.discountType = 'percentage'; +sampleCoupon1.product = sampleProduct1; +sampleCoupon1.vendor = sampleVendor1 as User; + +const sampleCoupon2 = new Coupon(); +sampleCoupon2.code = couponCode2; +sampleCoupon2.discountRate = 20; +sampleCoupon2.expirationDate = new Date('2026-01-01'); +sampleCoupon2.maxUsageLimit = 100; +sampleCoupon2.discountType = 'percentage'; +sampleCoupon2.product = sampleProduct1; +sampleCoupon2.vendor = sampleVendor1 as User; + +const sampleCoupon3 = new Coupon(); +sampleCoupon3.code = couponCode3; +sampleCoupon3.discountRate = 20; +sampleCoupon3.expirationDate = new Date('2026-01-01'); +sampleCoupon3.maxUsageLimit = 100; +sampleCoupon3.discountType = 'percentage'; +sampleCoupon3.product = sampleProduct2; +sampleCoupon3.vendor = sampleVendor1 as User; + +const expiredCoupon = new Coupon(); +expiredCoupon.code = expiredCouponCode; +expiredCoupon.discountRate = 20; +expiredCoupon.expirationDate = new Date('2023-01-01'); +expiredCoupon.maxUsageLimit = 100; +expiredCoupon.discountType = 'percentage'; +expiredCoupon.product = sampleProduct1; +expiredCoupon.vendor = sampleVendor1 as User; + +const finishedCoupon = new Coupon(); +finishedCoupon.code = finishedCouponCode; +finishedCoupon.discountRate = 20; +finishedCoupon.expirationDate = new Date('2028-01-01'); +finishedCoupon.maxUsageLimit = 0; +finishedCoupon.discountType = 'percentage'; +finishedCoupon.product = sampleProduct1; +finishedCoupon.vendor = sampleVendor1 as User; + +const moneyCoupon = new Coupon(); +moneyCoupon.code = moneyCouponCode; +moneyCoupon.discountRate = 50; +moneyCoupon.expirationDate = new Date('2028-01-01'); +moneyCoupon.maxUsageLimit = 10; +moneyCoupon.discountType = 'money'; +moneyCoupon.product = sampleProduct1; +moneyCoupon.vendor = sampleVendor1 as User; + +const sampleCart1 = { + id: cart1Id, + user: sampleBuyer1, + totalAmount: 200, +}; + +const sampleCartItem1 = { + id: cartItemId, + product: sampleProduct1, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + await userRepository?.save(sampleVendor1); + await userRepository?.save(sampleBuyer1); + await userRepository?.save(buyerNoCart); + await userRepository?.save(sampleVendor2); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save(sampleProduct1); + await productRepository?.save(sampleProduct2); + + const couponRepository = connection?.getRepository(Coupon); + await couponRepository?.save(sampleCoupon); + await couponRepository?.save(sampleCoupon1); + await couponRepository?.save(expiredCoupon); + await couponRepository?.save(sampleCoupon2); + await couponRepository?.save(sampleCoupon3); + await couponRepository?.save(finishedCoupon); + await couponRepository?.save(moneyCoupon); + + const cartRepository = connection?.getRepository(Cart); + await cartRepository?.save({ ...sampleCart1 }); + + const cartItemRepository = connection?.getRepository(CartItem); + await cartItemRepository?.save({ ...sampleCartItem1 }); +}); + +afterAll(async () => { + await cleanDatabase(); + + server.close(); +}); + +describe('Coupon Management System', () => { + describe('Create Coupon', () => { + it('should create a new coupon', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: 'NEWCOUPON10', + discountRate: 10, + expirationDate: '2025-12-31', + maxUsageLimit: 50, + discountType: 'PERCENTAGE', + product: product1Id, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 400 for invalid coupon data', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: '', + discountRate: 'invalid', + expirationDate: 'invalid-date', + maxUsageLimit: 'invalid', + discountType: 'INVALID', + product: 'invalid-product-id', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }, 10000); + + it('should return 403 if product not found', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: 'NEWCOUPON10', + discountRate: 10, + expirationDate: '2025-12-31', + maxUsageLimit: 50, + discountType: 'PERCENTAGE', + product: uuid(), + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(403); + }) + + it('should return 402 if coupon already exist', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: couponCode1, + discountRate: 10, + expirationDate: '2025-12-31', + maxUsageLimit: 50, + discountType: 'PERCENTAGE', + product: product1Id, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(402); + }) + + it('should return 500 if there is server error', async () => { + const response = await request(app) + .post(`/coupons/vendor/***** -- + ---/`) + .send({ + code: 'NEWCOUPON', + discountRate: 10, + expirationDate: '2025-12-31', + maxUsageLimit: 50, + discountType: 'PERCENTAGE', + product: product1Id, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(500); + }) + }); + + describe('Get All Coupons', () => { + it('should retrieve all coupons for a vendor', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.data).toBeInstanceOf(Object); + }, 10000); + + it('should return 404 if no coupons found', async () => { + const newVendorId = uuid(); + const response = await request(app) + .get(`/coupons/vendor/${newVendorId}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(newVendorId, 'newvendor@example.com')}`); + + expect(response.status).toBe(401); + }, 10000); + }); + + describe('Vendor access all Coupon', () => { + it('should return all coupons', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 404 for invalid vendor id', async () => { + const invalidVendorId = uuid(); + const response = await request(app) + .get(`/coupons/vendor/${invalidVendorId}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('User not found'); + }, 10000); + + it('should return 404 if no coupon found for VENDOR', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor2Id}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(404); + }) + + it('should return 500 server error', async () => { + const response = await request(app) + .get(`/coupons/vendor/uihoji 090j hh =/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(500); + }) + }); + + describe('Read Coupon', () => { + it('should read a single coupon by code', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/checkout/${couponCode}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + + it('should return 404 for invalid coupon code', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/checkout/${invalidCouponCode}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid coupon'); + }, 10000); + }); + + describe('Update Coupon', () => { + it('should update an existing coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode1}`) + .send({ + code: 'KAGAHEBUZO04', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should validate coupon update input', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode1}`) + .send() + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }) + + it('should return 404 for updating a non-existent coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${invalidCouponCode}`) + .send({ + discountRate: 25, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + }, 10000); + + it('should return 200 for updating a discount of coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode}`) + .send({ discountRate: 25 }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + + it('should return 200 for updating a expirationDate of coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode}`) + .send({ expirationDate: '2025-12-31' }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + + it('should return 200 for updating a maxUsageLimit of coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode}`) + .send({ maxUsageLimit: 40 }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + + it('should return 200 for updating a discountType of coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode}`) + .send({ discountType: 'MONEY' }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + }); + + describe('Delete Coupon', () => { + it('should delete an existing coupon', async () => { + const response = await request(app) + .delete(`/coupons/vendor/${vendor1Id}/checkout/delete`) + .send({ + code: couponCode, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 404 for deleting a non-existent coupon', async () => { + const response = await request(app) + .delete(`/coupons/vendor/${vendor1Id}/checkout/delete`) + .send({ + code: invalidCouponCode, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid coupon'); + }, 10000); + }); +}); + +describe('Buyer Coupon Application', () => { + describe('Checking Coupon Conditions', () => { + it('should return 400 when no coupon submitted', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon Code is required'); + }); + it('should return 404 if coupon code is not found in the database', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: 'InvalidCode', + }); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Invalid Coupon Code'); + }); + it('should not allow use of expired tokens', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: expiredCoupon.code, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon is expired'); + }); + it('should not allow use of coupon that reach maximum users', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: finishedCoupon.code, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon Discount Ended'); + }); + it('Should not work when the product is not in cart', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: sampleCoupon3.code, + }); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('No product in Cart with that coupon code'); + }); + }); + + describe('Giving discount according the the product coupon', () => { + it('Should give discont when discount-type is percentage', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: sampleCoupon2.code, + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe( + `Coupon Code successfully activated discount on product: ${sampleProduct1.name}` + ); + }); + it('Should give discont when discount-type is money', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: moneyCoupon.code, + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe( + `Coupon Code successfully activated discount on product: ${sampleProduct1.name}` + ); + }); + }); +}); diff --git a/src/__test__/errorHandler.test.ts b/src/__test__/errorHandler.test.ts new file mode 100644 index 0000000..cf079f0 --- /dev/null +++ b/src/__test__/errorHandler.test.ts @@ -0,0 +1,47 @@ +import { Request, Response } from 'express'; +import { CustomError, errorHandler } from '../middlewares/errorHandler'; + +describe('CustomError', () => { + it('should create a CustomError object with statusCode and status properties', () => { + const message = 'Test error message'; + const statusCode = 404; + const customError = new CustomError(message, statusCode); + expect(customError.message).toBe(message); + expect(customError.statusCode).toBe(statusCode); + expect(customError.status).toBe('fail'); + }); +}); + +describe('errorHandler', () => { + it('should send correct response with status code and message', () => { + const err = new CustomError('Test error message', 404); + const req = {} as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn(); + errorHandler(err, req, res, next); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + status: 404, + message: 'Test error message', + }); + }); + it('should handle errors with status code 500', () => { + const err = new CustomError('something went wrong', 500); + const req = {} as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn(); + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + status: 500, + message: 'something went wrong', + }); + }); +}); diff --git a/src/__test__/getProduct.test.ts b/src/__test__/getProduct.test.ts new file mode 100644 index 0000000..dc9ac8b --- /dev/null +++ b/src/__test__/getProduct.test.ts @@ -0,0 +1,158 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { Cart } from '../entities/Cart'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const BuyerID = uuid(); +const product1Id = uuid(); +const Invalidproduct = '11278df2-d026-457a-9471-4749f038df68'; +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1o', + lastName: 'user', + email: 'vendor10@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; +const sampleBuyer1: UserInterface = { + id: BuyerID, + firstName: 'vendor1o', + lastName: 'user', + email: 'buyer10@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '000380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product single', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; +let cardID : string; +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1}); + await userRepository?.save({ ...sampleBuyer1 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 60000); +}); +describe('Get single product', () => { + it('should get a single product', async () => { + const response = await request(app) + .get(`/product/${product1Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.product).toBeDefined; + expect(response.body.product.id).toBe(product1Id); + }, 10000); + + it('should return 400 for invalid product Id', async () => { + const response = await request(app) + .get(`/product/non-existing-id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid product ID'); + }, 10000); + it('should return 404 for product not found', async () => { + const response = await request(app) + .get(`/product/${Invalidproduct}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found'); + }, 10000); +}); +describe('Cart Order and payment functionalities', () => { + it('should create a cart for a product', async () => { + const productId = product1Id; + const quantity = 8; + + const token = getAccessToken(BuyerID, sampleBuyer1.email); + + const response = await request(app) + .post('/cart') + .set('Authorization', `Bearer ${token}`) + .send({ productId, quantity }); + + + expect(response.status).toBe(201); + expect(response.body.data.cart).toBeDefined(); + cardID = JSON.stringify(response.body.data.cart.id) + }); + +} +) \ No newline at end of file diff --git a/src/__test__/index.test.ts b/src/__test__/index.test.ts new file mode 100644 index 0000000..dfa39c4 --- /dev/null +++ b/src/__test__/index.test.ts @@ -0,0 +1,107 @@ +// index.test.ts + +import request from 'supertest'; +import { app, server } from '../index'; +import { dbConnection } from '../startups/dbConnection'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + await dbConnection(); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Express App', () => { + it('should have JSON parsing enabled', async () => { + const response = await request(app) + .get('/test') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + }); + + it('Should respond to posting route', async () => { + const response = await request(app) + .post('/test/posting') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + }); + + it('should respond to a valid route', async () => { + const response = await request(app) + .get('/test') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Route works!'); + }); + + it('should not respond to invalid route', async () => { + const response = await request(app) + .get('/testing/mon') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(404); + }); + + it('should respond to an invalid route with an appropriate message', async () => { + const response = await request(app) + .get('/mon') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(404); + }); +}); + +describe('Application JSON', () =>{ +it('Should respond to json', async () =>{ +const data ={ + name: 'John', + age: 20, + gender:'male' +}; +const response = await request(app) +.post('/test/posting') +.set('Content-Type', 'application/json') +.send(data); + +expect(response.statusCode).toBe(200); +}); +}); + +describe('APIs protection', () => { + it('should respond with a 401 status for unauthorized request', async () => { + const response = await request(app) + .get('/test/secure') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(401); + }); + + it('should respond with a 500 status for server errors', async () => { + const response = await request(app) + .get('/test/error') + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(500); + }); + + it('should respond with correct data', async () => { + const data = { + name: 'John', + age: 20, + gender: 'male' + }; + const response = await request(app) + .post('/test/posting') + .set('Content-Type', 'application/json') + .send(data); + + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined; + }); +}); \ No newline at end of file diff --git a/src/__test__/index.utils.test.ts b/src/__test__/index.utils.test.ts new file mode 100644 index 0000000..60c7ded --- /dev/null +++ b/src/__test__/index.utils.test.ts @@ -0,0 +1,35 @@ +import { server } from '..'; +import { formatMoney, formatDate } from '../utils/index'; + +describe('Utility Functions', () => { + describe('formatMoney', () => { + it('should format a number as currency with default currency RWF', () => { + expect(formatMoney(1234.56)).toBeDefined(); + }); + + it('should format a number as currency with specified currency', () => { + expect(formatMoney(1234.56, 'USD')).toBe('$1,234.56'); + expect(formatMoney(1234.56, 'EUR')).toBe('€1,234.56'); + }); + + it('should format a number with no cents if amount is a whole number', () => { + expect(formatMoney(1234)).toBeDefined(); + }); + }); + + describe('formatDate', () => { + it('should format a date string into a more readable format', () => { + const date = new Date('2024-05-28'); + expect(formatDate(date)).toBe('May 28, 2024'); + }); + + it('should format another date correctly', () => { + const date = new Date('2020-01-01'); + expect(formatDate(date)).toBe('January 1, 2020'); + }); + + it('should handle invalid date strings gracefully', () => { + expect(formatDate(new Date('invalid-date'))).toBe('Invalid Date'); + }); + }); +}); diff --git a/src/__test__/isAllowed.test.ts b/src/__test__/isAllowed.test.ts index a6bd2ef..d4636f1 100644 --- a/src/__test__/isAllowed.test.ts +++ b/src/__test__/isAllowed.test.ts @@ -1,10 +1,12 @@ -import { NextFunction, Request, Response } from "express"; -import { checkUserStatus } from "../middlewares/isAllowed"; +import { NextFunction, Request, Response } from 'express'; +import { checkUserStatus } from '../middlewares/isAllowed'; import { dbConnection } from '../startups/dbConnection'; import { getConnection } from 'typeorm'; import { User } from '../entities/User'; import { responseError } from '../utils/response.utils'; import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { server } from '..'; jest.mock('../utils/response.utils'); @@ -16,76 +18,76 @@ const activeUserId = uuid(); const suspendedUserId = uuid(); beforeAll(async () => { - const connection = await dbConnection(); - - const userRepository = connection?.getRepository(User); - - const activeUser = new User(); - activeUser.id = activeUserId; - activeUser.firstName = 'John2'; - activeUser.lastName = 'Doe'; - activeUser.email = 'active.doe@example.com'; - activeUser.password = 'password'; - activeUser.gender = 'Male'; - activeUser.phoneNumber = '12347'; - activeUser.photoUrl = 'https://example.com/photo.jpg'; - - await userRepository?.save(activeUser); - - const suspendedUser = new User(); - suspendedUser.id = suspendedUserId; - suspendedUser.firstName = 'John2'; - suspendedUser.lastName = 'Doe'; - suspendedUser.email = 'suspended.doe@example.com'; - suspendedUser.password = 'password'; - suspendedUser.gender = 'Male'; - suspendedUser.status = 'suspended'; - suspendedUser.phoneNumber = '12349'; - suspendedUser.photoUrl = 'https://example.com/photo.jpg'; - - await userRepository?.save(suspendedUser); + const connection = await dbConnection(); + + const userRepository = await connection?.getRepository(User); + + const activeUser = new User(); + activeUser.id = activeUserId; + activeUser.firstName = 'John2'; + activeUser.lastName = 'Doe'; + activeUser.email = 'active.doe@example.com'; + activeUser.password = 'password'; + activeUser.gender = 'Male'; + activeUser.phoneNumber = '12347'; + activeUser.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(activeUser); + + const suspendedUser = new User(); + suspendedUser.id = suspendedUserId; + suspendedUser.firstName = 'John2'; + suspendedUser.lastName = 'Doe'; + suspendedUser.email = 'suspended.doe@example.com'; + suspendedUser.password = 'password'; + suspendedUser.gender = 'Male'; + suspendedUser.status = 'suspended'; + suspendedUser.phoneNumber = '12349'; + suspendedUser.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(suspendedUser); }); afterAll(async () => { - const connection = getConnection(); - const userRepository = connection.getRepository(User); - - // Close the connection to the test database - await connection.close(); + await cleanDatabase(); + server.close(); }); describe('Middleware - checkUserStatus', () => { - beforeEach(() => { - reqMock = {}; - resMock = { - status: jest.fn().mockReturnThis(), - json: jest.fn() - }; - nextMock = jest.fn(); - }); - - it('should return 401 if user is not authenticated', async () => { - await checkUserStatus(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalledWith(resMock, 401, 'Authentication required'); - }); - - it('should return 401 if user is not found', async () => { - reqMock = { user: { id: uuid() } }; - - await checkUserStatus(reqMock as Request, resMock as Response, nextMock); - - expect(responseError).toHaveBeenCalledWith(resMock, 401, 'User not found'); - }); - - it('should pass if user status is active', async () => { - reqMock = { user: { id: activeUserId } }; - await checkUserStatus(reqMock as Request, resMock as Response, nextMock); - expect(nextMock).toHaveBeenCalled(); - }); - - it('should return 403 if user status is suspended', async () => { - reqMock = { user: { id: suspendedUserId } }; - await checkUserStatus(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalledWith(resMock, 403, 'You have been suspended. Please contact our support team.'); - }); + beforeEach(() => { + reqMock = {}; + resMock = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + nextMock = jest.fn(); + }); + + it('should return 401 if user is not authenticated', async () => { + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalledWith(resMock, 401, 'Authentication required'); + }); + + it('should return 401 if user is not found', async () => { + reqMock = { user: { id: uuid() } }; + + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + + expect(responseError).toHaveBeenCalledWith(resMock, 401, 'User not found'); + }); + + it('should pass if user status is active', async () => { + reqMock = { user: { id: activeUserId } }; + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(nextMock).toHaveBeenCalled(); + }); + it('should return 403 if user status is suspended', async () => { + reqMock = { user: { id: suspendedUserId } }; + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalledWith( + resMock, + 403, + 'You have been suspended. Please contact our support team.' + ); + }); }); diff --git a/src/__test__/logger.test.ts b/src/__test__/logger.test.ts new file mode 100644 index 0000000..71a766d --- /dev/null +++ b/src/__test__/logger.test.ts @@ -0,0 +1,80 @@ +import { dbConnection } from '../startups/dbConnection'; +import logger from '../utils/logger'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { server } from '../../src/index'; + +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + http: jest.fn(), + }, +})); + +beforeAll(async () => { + const connection = await dbConnection(); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Logger', () => { + it('should create a logger with the correct configuration', () => { + expect(logger).toBeDefined(); + }); + + it('should log messages with the correct level and format', () => { + const testMessage = 'Test log message'; + const testLevel = 'info'; + + logger.log(testLevel, testMessage); + + expect(logger.log).toHaveBeenCalledWith(testLevel, testMessage); + }); + + it('should correctly handle info level logs', () => { + const testMessage = 'Test info message'; + + logger.info(testMessage); + + expect(logger.info).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle warn level logs', () => { + const testMessage = 'Test warn message'; + + logger.warn(testMessage); + + expect(logger.warn).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle error level logs', () => { + const testMessage = 'Test error message'; + + logger.error(testMessage); + + expect(logger.error).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle debug level logs', () => { + const testMessage = 'Test debug message'; + + logger.debug(testMessage); + + expect(logger.debug).toHaveBeenCalledWith(testMessage); + }); + + it('should correctly handle http level logs', () => { + const testMessage = 'Test http message'; + + logger.http(testMessage); + + expect(logger.http).toHaveBeenCalledWith(testMessage); + }); +}); \ No newline at end of file diff --git a/src/__test__/logout.test.ts b/src/__test__/logout.test.ts index 19cdf70..2ae713c 100644 --- a/src/__test__/logout.test.ts +++ b/src/__test__/logout.test.ts @@ -2,15 +2,16 @@ import request from 'supertest'; import { app, server } from '../index'; import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; import { User } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; beforeAll(async () => { // Connect to the test database - const connectionOptions = await getConnectionOptions(); - await createConnection({ ...connectionOptions, name: 'testConnection' }); + await createConnection(); }); afterAll(async () => { - await getConnection('testConnection').close(); + await cleanDatabase(); + server.close(); }); @@ -71,4 +72,4 @@ describe('POST /user/logout', () => { expect(res.status).toBe(400); expect(res.body).toEqual({ Message: 'Access denied. You must be logged in' }); }); -}); +}); \ No newline at end of file diff --git a/src/__test__/notification.test.ts b/src/__test__/notification.test.ts new file mode 100644 index 0000000..8c4b2e6 --- /dev/null +++ b/src/__test__/notification.test.ts @@ -0,0 +1,231 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { Notification } from '../entities/Notification'; +import { NotificationItem } from '../entities/NotificationItem'; +import exp from 'constants'; + +const user1Id = uuid(); +const user2Id = uuid(); +const user3Id = uuid(); + +const notificationId = uuid(); +const notification2Id = uuid(); + +const notificationItemId = uuid(); +const notificationItem2Id = uuid(); +const notificationItem3Id = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleUser: UserInterface = { + id: user1Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendor1@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; +const sampleUser2: UserInterface = { + id: user2Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleUser3: UserInterface = { + id: user3Id, + firstName: 'buyer2', + lastName: 'user', + email: 'buyer2@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '1347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleNotification = { + id: notificationId, + unRead: 2, + user: sampleUser +}; + +const sampleNotification2 = { + id: notification2Id, + unRead: 1, + user: sampleUser3 +}; + +const sampleNotificationItem = { + id: notificationItemId, + content: "This is notification content for test", + type: 'order', + isRead: false, + link: '/link/to/more-detail', + notification: sampleNotification +}; + + +const sampleNotificationItem2 = { + id: notificationItem2Id, + content: "This is notification content for test", + type: 'order', + isRead: false, + link: '/link/to/more-detail', + notification: sampleNotification +}; + +const sampleNotificationItem3 = new NotificationItem(); +sampleNotificationItem3.id = notificationItem3Id; +sampleNotificationItem3.content = "This is notification content for test"; +sampleNotificationItem3.type = 'order'; +sampleNotificationItem3.isRead = false; +sampleNotificationItem3.link = '/link/to/more-details'; + + +beforeAll(async () => { + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleUser }); + await userRepository?.save({ ...sampleUser2 }); + await userRepository?.save({ ...sampleUser3 }); + + const notificationRepository = connection?.getRepository(Notification); + await notificationRepository?.save({ ...sampleNotification }); + + const result = await notificationRepository?.save({ ...sampleNotification2 }); + + const notificationItemRepository = connection?.getRepository(NotificationItem); + if (result) sampleNotificationItem3.notification = result; + await notificationItemRepository?.save({ ...sampleNotificationItem }); + await notificationItemRepository?.save({ ...sampleNotificationItem2 }); + await notificationItemRepository?.save({ ...sampleNotificationItem3 }); +}); + +afterAll(async () => { + await cleanDatabase(); + + server.close(); +}); + +describe('Notifications Tests', () => { + it('Should return all notification for authenticated user', async () => { + const response = await request(app) + .get('/notification/') + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Notifications retrieved successfully'); + }); + + it('Should return empty object if user doesn\'t have any notification', async () => { + const response = await request(app) + .get('/notification/') + .set('Authorization', `Bearer ${getAccessToken(sampleUser2.id!, sampleUser2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('User doesn\'t have any notifications.'); + }); + + it('should update selected notifications, if there valid Ids and exist in DB', async () => { + const response = await request(app) + .put(`/notification/`) + .send({ + notificationIds: [notificationItemId, 'sdfsdfd'] + }) + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('1 of 2 Notification(s) was successfully updated.'); + }); + + it('should return 400, if no notifications ids provided to update', async () => { + const response = await request(app) + .put(`/notification/`) + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(400); + }); + + it('should update all unread notifications for authenticated user', async () => { + const response = await request(app) + .put(`/notification/all`) + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('All your unread notifications was successfully updated as read.'); + }); + + it('should not update any notification if user doesn\'t have unread notifications.', async () => { + const response = await request(app) + .put(`/notification/all`) + .set('Authorization', `Bearer ${getAccessToken(sampleUser2.id!, sampleUser2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('User doesn\'t have any unread notifications.'); + }); + + it('should delete selected notifications, if there valid Ids and exist in DB', async () => { + const response = await request(app) + .delete(`/notification/`) + .send({ + notificationIds: [notificationItem3Id, 'sdfsdfd'] + }) + .set('Authorization', `Bearer ${getAccessToken(sampleUser3.id!, sampleUser3.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('1 of 2 Notification(s) was successfully deleted.'); + }); + + it('should return 400, if no notifications ids provided to delete', async () => { + const response = await request(app) + .delete(`/notification/`) + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(400); + }); + + it('should delete all notification for authenticated user.', async () => { + const response = await request(app) + .delete(`/notification/all`) + .set('Authorization', `Bearer ${getAccessToken(sampleUser.id!, sampleUser.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('All Notifications was successfully deleted.'); + }); + it('Should not delete any notification, if user doesn\'t have notifications', async () => { + const response = await request(app) + .delete('/notification/all') + .set('Authorization', `Bearer ${getAccessToken(sampleUser2.id!, sampleUser2.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('User doesn\'t have notifications'); + }); +}); \ No newline at end of file diff --git a/src/__test__/oauth.test.ts b/src/__test__/oauth.test.ts new file mode 100644 index 0000000..877d63b --- /dev/null +++ b/src/__test__/oauth.test.ts @@ -0,0 +1,24 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { User } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + await createConnection(); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); +describe('authentication routes test', () => { + it('should redirect to the google authentication page', async () => { + const response = await request(app).get('/user/google-auth'); + expect(response.statusCode).toBe(302); + }); + it('should redirect after google authentication', async () => { + const response = await request(app).get('/user/auth/google/callback'); + expect(response.statusCode).toBe(302); + }); +}); diff --git a/src/__test__/orderManagement.test.ts b/src/__test__/orderManagement.test.ts new file mode 100644 index 0000000..846b9d8 --- /dev/null +++ b/src/__test__/orderManagement.test.ts @@ -0,0 +1,390 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { Order } from '../entities/Order'; +import { OrderItem } from '../entities/OrderItem'; +import { VendorOrders } from '../entities/vendorOrders'; +import { VendorOrderItem } from '../entities/VendorOrderItem'; + +const adminId = uuid(); +const vendorId = uuid(); +const vendor2Id = uuid(); +const buyerId = uuid(); + +const productId = uuid(); +const product2Id = uuid(); + +const orderId = uuid(); +const orderItemId = uuid(); +const order2Id = uuid(); +const order2ItemId = uuid(); + +const vendorOrderId = uuid(); +const vendorOrderItemId = uuid(); +const vendorOrder2Id = uuid(); +const vendorOrder2ItemId = uuid(); +const catId = uuid(); + +console.log(adminId, vendorId, buyerId); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleAdmin: UserInterface = { + id: adminId, + firstName: 'admin', + lastName: 'user', + email: 'admin@example.com', + password: 'password', + userType: 'Admin', + gender: 'Male', + phoneNumber: '126380997', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'ADMIN', +}; +const sampleVendor: UserInterface = { + id: vendorId, + firstName: 'vendor', + lastName: 'user', + email: 'vendor@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'VENDOR', +}; +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'vendor', + lastName: 'user', + email: 'vendor2@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '18090296347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'VENDOR', +}; +const sampleBuyer: UserInterface = { + id: buyerId, + firstName: 'buyer', + lastName: 'user', + email: 'buyer@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '6380996347', + photoUrl: 'https://example.com/photo.jpg', + verified: true, + role: 'BUYER', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct = { + id: productId, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor, + categories: [sampleCat], +}; +const sampleProduct2 = { + id: product2Id, + name: 'test product2', + description: 'amazing products', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor, + categories: [sampleCat], +}; + +const sampleOrder = { + id: orderId, + totalPrice: 400, + quantity: 2, + orderDate: new Date(), + buyer: sampleBuyer, + orderStatus: 'received', + address: 'Rwanda, Kigali, KK20st', +}; +const sampleOrder2 = { + id: order2Id, + totalPrice: 400, + quantity: 2, + orderDate: new Date(), + buyer: sampleBuyer, + orderStatus: 'order placed', + address: 'Rwanda, Kigali, KK20st', +}; + +const sampleOrderItem = { + id: orderItemId, + price: 200, + quantity: 2, + order: sampleOrder, + product: sampleProduct, +}; + +const sampleVendorOrder = { + id: vendorOrderId, + totalPrice: 400, + quantity: 2, + vendor: sampleVendor, + order: sampleOrder, + buyer: sampleBuyer, + orderStatus: 'pending', +}; + +const sampleVendorOrderItem = { + 'id': vendorOrderItemId, + 'price/unit': 200, + 'quantity': 2, + 'order': sampleVendorOrder, + 'product': sampleProduct, +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save([sampleAdmin, sampleVendor, sampleVendor2, sampleBuyer]); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct }); + + // Order Management + const orderRepository = connection?.getRepository(Order); + await orderRepository?.save([sampleOrder, sampleOrder2]); + + const orderItemRepository = connection?.getRepository(OrderItem); + await orderItemRepository?.save({ ...sampleOrderItem }); + + const vendorOrderRepository = connection?.getRepository(VendorOrders); + await vendorOrderRepository?.save({ ...sampleVendorOrder }); + + const vendorOrderItemRepository = connection?.getRepository(VendorOrderItem); + await vendorOrderItemRepository?.save({ ...sampleVendorOrderItem }); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('Vendor Order Management', () => { + describe('Fetching vendor Order(s)', () => { + it('Should return all vendor orders', async () => { + const response = await request(app) + .get('/product/vendor/orders') + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.orders).toBeDefined(); + }); + + it("Should return empty array if vendor don't have any order for buyer", async () => { + const response = await request(app) + .get('/product/vendor/orders') + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.orders).toEqual([]); + }); + + it('Should return single vendor order', async () => { + const response = await request(app) + .get(`/product/vendor/orders/${vendorOrderId}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.order).toBeDefined(); + }); + + it('return 404, for non existing vendor order', async () => { + const response = await request(app) + .get(`/product/vendor/orders/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Order Not Found.'); + }); + + it('return 400, for invalid vendor order id ', async () => { + const response = await request(app) + .get(`/product/vendor/orders/32df3`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe(`invalid input syntax for type uuid: "32df3"`); + }); + }); + describe('Updating vendor order', () => { + it('should update the order', async () => { + const response = await request(app) + .put(`/product/vendor/orders/${vendorOrderId}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`) + .send({ + orderStatus: 'delivered', + }); + + expect(response.statusCode).toBe(200); + }); + it('should not update if orderStatus in not among defined ones', async () => { + const response = await request(app) + .put(`/product/vendor/orders/${vendorOrderId}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`) + .send({ + orderStatus: 'fakeOrderStatus', + }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Please provide one of defined statuses.'); + }); + it('should not update, return 404 for non existing vendor order', async () => { + const response = await request(app) + .put(`/product/vendor/orders/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`) + .send({ + orderStatus: 'is-accepted', + }); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Order Not Found.'); + }); + it('should not update, if the order has already been cancelled or completed', async () => { + const response = await request(app) + .put(`/product/vendor/orders/${vendorOrderId}`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`) + .send({ + orderStatus: 'is-accepted', + }); + + expect(response.statusCode).toBe(409); + }); + it('return 400, for invalid vendor order id ', async () => { + const response = await request(app) + .put(`/product/vendor/orders/32df3`) + .set('Authorization', `Bearer ${getAccessToken(vendorId, sampleVendor.email)}`) + .send({ + orderStatus: 'is-accepted', + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe(`invalid input syntax for type uuid: "32df3"`); + }); + }); +}); + +describe('Admin Order Management', () => { + describe('Fetching buyer and vendor Order(s)', () => { + it("Should return all orders with it's buyer and related vendors", async () => { + const response = await request(app) + .get('/product/admin/orders') + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.orders).toBeDefined(); + }); + + it('Should return single order details', async () => { + const response = await request(app) + .get(`/product/admin/orders/${orderId}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.order).toBeDefined(); + }); + + it('return 404, for non existing order', async () => { + const response = await request(app) + .get(`/product/admin/orders/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Order Not Found.'); + }); + + it('return 400, for invalid order id ', async () => { + const response = await request(app) + .get(`/product/admin/orders/32df3`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe(`invalid input syntax for type uuid: "32df3"`); + }); + }); + describe('Updating buyer and vendor order', () => { + it('should not update, return 404 for non existing order', async () => { + const response = await request(app) + .put(`/product/admin/orders/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Order Not Found.'); + }); + it('should update the order', async () => { + const response = await request(app) + .put(`/product/admin/orders/${orderId}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.order).toBeDefined(); + }); + it('should not update if it has already been completed(closed)', async () => { + const response = await request(app) + .put(`/product/admin/orders/${orderId}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.statusCode).toBe(409); + expect(response.body.message).toBe('The order has already been completed.'); + }); + + it('should not update, if the order has not been marked as received by buyer', async () => { + const response = await request(app) + .put(`/product/admin/orders/${order2Id}`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.statusCode).toBe(409); + expect(response.body.message).toBe('Order closure failed: The buyer has not received the item yet.'); + }); + + it('return 400, for invalid order id ', async () => { + const response = await request(app) + .put(`/product/admin/orders/32df3`) + .set('Authorization', `Bearer ${getAccessToken(adminId, sampleAdmin.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe(`invalid input syntax for type uuid: "32df3"`); + }); + }); +}); diff --git a/src/__test__/product.entities.test.ts b/src/__test__/product.entities.test.ts new file mode 100644 index 0000000..5f9fc1d --- /dev/null +++ b/src/__test__/product.entities.test.ts @@ -0,0 +1,175 @@ +import { validate } from 'class-validator'; +import { Repository } from 'typeorm'; +import { Product } from '../entities/Product'; +import { User } from '../entities/User'; +import { Category } from '../entities/Category'; +import { Coupon } from '../entities/coupon'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { dbConnection } from '../startups/dbConnection'; +import { server } from '../index'; + +// Sample data +const catId = uuid(); +const vendor3Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const couponId1 = uuid(); +const couponId2 = uuid(); +const couponCode1 = 'DISCOUNT10'; +const couponCode2 = 'DISCOUNT20'; + +if (!process.env.TEST_USER_EMAIL + || !process.env.TEST_USER_PASS) throw new Error('TEST_USER_PASS or TEST_USER_EMAIL not set in .env'); + +const sampleVendor3 = new User(); +sampleVendor3.id = vendor3Id; +sampleVendor3.firstName = 'Vendor3'; +sampleVendor3.lastName = 'User'; +sampleVendor3.email = process.env.TEST_USER_EMAIL; +sampleVendor3.password = process.env.TEST_USER_PASS; +sampleVendor3.userType = 'Vendor'; +sampleVendor3.gender = 'Male'; +sampleVendor3.phoneNumber = '32638099634'; +sampleVendor3.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor3.role = 'VENDOR'; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'Test Product 1'; +sampleProduct1.description = 'Amazing product 1'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor3; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'Test Product 2'; +sampleProduct2.description = 'Amazing product 2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct2.newPrice = 250; +sampleProduct2.quantity = 15; +sampleProduct2.vendor = sampleVendor3; + +const sampleCoupon1 = new Coupon(); +sampleCoupon1.id = couponId1; +sampleCoupon1.code = couponCode1; +sampleCoupon1.discountRate = 20; +sampleCoupon1.expirationDate = new Date('2025-01-01'); +sampleCoupon1.maxUsageLimit = 100; +sampleCoupon1.discountType = 'percentage'; +sampleCoupon1.product = sampleProduct1; +sampleCoupon1.vendor = sampleVendor3; + +const sampleCoupon2 = new Coupon(); +sampleCoupon2.id = couponId2; +sampleCoupon2.code = couponCode2; +sampleCoupon2.discountRate = 15; +sampleCoupon2.expirationDate = new Date('2025-01-01'); +sampleCoupon2.maxUsageLimit = 50; +sampleCoupon2.discountType = 'percentage'; +sampleCoupon2.product = sampleProduct2; +sampleCoupon2.vendor = sampleVendor3; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +let productRepository: Repository; +let userRepository: Repository; +let categoryRepository: Repository; +let couponRepository: Repository; + +beforeAll(async () => { + const connection = await dbConnection(); + if (!connection) { + console.error('Failed to connect to the database'); + return; + } + + userRepository = connection.getRepository(User); + categoryRepository = connection.getRepository(Category); + couponRepository = connection.getRepository(Coupon); + productRepository = connection.getRepository(Product); + + await userRepository.save(sampleVendor3); + await categoryRepository.save(sampleCat); + + const category1 = categoryRepository.create({ name: 'Category 1' }); + const category2 = categoryRepository.create({ name: 'Category 2' }); + await categoryRepository.save([category1, category2]); + + sampleProduct1.categories = [category1]; + sampleProduct2.categories = [category2]; + await productRepository.save([sampleProduct1, sampleProduct2]); + await couponRepository.save([sampleCoupon1, sampleCoupon2]); +}); + +afterAll(async () => { + await cleanDatabase(); + const connection = await dbConnection(); + if (connection) { + await connection.close(); + } + server.close(); +}); + +describe('Product Entity', () => { + it('should create all entities related to product entity', async () => { + const product = await productRepository.save(sampleProduct2); + expect(product).toBeDefined(); + }); + + it('should not validate a product with missing required fields', async () => { + const product = new Product(); + const errors = await validate(product); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should enforce array constraints on images', async () => { + const product = productRepository.create({ + id: uuid(), + vendor: sampleVendor3, + name: 'Sample Product', + description: 'This is a sample product', + images: [], + newPrice: 100.0, + quantity: 10, + isAvailable: true, + }); + + const errors = await validate(product); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].constraints?.arrayNotEmpty).toBeDefined(); + }); + + it('should handle relationships correctly', async () => { + const category1 = await categoryRepository.findOne({ where: { name: 'Category 1' } }); + const category2 = await categoryRepository.findOne({ where: { name: 'Category 2' } }); + + const product = productRepository.create({ + id: uuid(), + vendor: sampleVendor3, + name: 'Sample Product', + description: 'This is a sample product', + images: ['image1.jpg', 'image2.jpg'], + newPrice: 100.0, + quantity: 10, + isAvailable: true, + categories: [category1 as Category, category2 as Category], + }); + + await productRepository.save(product); + + const savedProduct = await productRepository.findOne({ + where: { id: product.id }, + relations: ['vendor', 'categories', 'coupons'], + }); + + expect(savedProduct).toBeDefined(); + expect(savedProduct?.vendor).toBeDefined(); + expect(savedProduct?.categories).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/src/__test__/productStatus.test.ts b/src/__test__/productStatus.test.ts new file mode 100644 index 0000000..8e8b42a --- /dev/null +++ b/src/__test__/productStatus.test.ts @@ -0,0 +1,237 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const vendor2Id = uuid(); +const vendor3Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const product3Id = uuid(); +const product4Id = uuid(); +const product5Id = uuid(); +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1 = new User(); +sampleVendor1.id = vendor1Id; +sampleVendor1.firstName = 'vendor1'; +sampleVendor1.lastName = 'user'; +sampleVendor1.email = 'vendora1@example.com'; +sampleVendor1.password = 'password'; +sampleVendor1.userType = 'Vendor'; +sampleVendor1.gender = 'Male'; +sampleVendor1.phoneNumber = '126380996347'; +sampleVendor1.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor1.role = 'VENDOR'; + +const sampleVendor2 = new User(); +sampleVendor2.id = vendor2Id; +sampleVendor2.firstName = 'vendor2'; +sampleVendor2.lastName = 'user'; +sampleVendor2.email = 'vendora2@example.com'; +sampleVendor2.password = 'password'; +sampleVendor2.userType = 'Vendor'; +sampleVendor2.gender = 'Male'; +sampleVendor2.phoneNumber = '1638099634'; +sampleVendor2.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor2.role = 'VENDOR'; + +const sampleVendor3 = new User(); +sampleVendor3.id = vendor3Id; +sampleVendor3.firstName = 'vendor3 ddss'; +sampleVendor3.lastName = 'user'; +sampleVendor3.email = 'vendor2@example.com'; +sampleVendor3.password = 'password'; +sampleVendor3.userType = 'Vendor'; +sampleVendor3.gender = 'Male'; +sampleVendor3.phoneNumber = '1638099634'; +sampleVendor3.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor3.role = 'VENDOR'; + +const sampleCat = new Category(); +sampleCat.id = catId; +sampleCat.name = 'accessories'; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'test product'; +sampleProduct1.description = 'amazing product'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor1; +sampleProduct1.categories = [sampleCat]; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'test product2'; +sampleProduct2.description = 'amazing product2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct2.newPrice = 200; +sampleProduct2.quantity = 10; +sampleProduct2.vendor = sampleVendor1; +sampleProduct2.categories = [sampleCat]; + +const sampleProduct3 = new Product(); +sampleProduct3.id = product3Id; +sampleProduct3.name = 'testing product3'; +sampleProduct3.description = 'amazing product3'; +sampleProduct3.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct3.newPrice = 200; +sampleProduct3.quantity = 10; +sampleProduct3.vendor = sampleVendor2; +sampleProduct3.categories = [sampleCat]; + +const sampleProduct4 = new Product(); +sampleProduct4.id = product4Id; +sampleProduct4.name = 'testingmkknkkjiproduct4'; +sampleProduct4.description = 'amazing product4'; +sampleProduct4.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct4.newPrice = 200; +sampleProduct4.quantity = 10; +sampleProduct4.vendor = sampleVendor2; +sampleProduct4.categories = [sampleCat]; + +const sampleProduct5 = new Product(); +sampleProduct5.id = product5Id; +sampleProduct5.name = 'Here is testing with product5'; +sampleProduct5.description = 'amazing product5'; +sampleProduct5.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct5.newPrice = 20; +sampleProduct5.quantity = 10; +sampleProduct5.vendor = sampleVendor1; +sampleProduct5.categories = [sampleCat]; +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + const savedCategory = await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + const savedVendor1 = await userRepository?.save({ ...sampleVendor1 }); + const savedVendor2 = await userRepository?.save({ ...sampleVendor2 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1, vendor: savedVendor1, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct2, vendor: savedVendor1, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct3, vendor: savedVendor2, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct5, vendor: savedVendor1, categories: [savedCategory as Category] }); + + sampleProduct2.expirationDate = new Date(2020 - 3 - 24); + productRepository?.save(sampleProduct2); + + sampleProduct5.quantity = 0; + productRepository?.save(sampleProduct5); +}); + +afterAll(async () => { + await cleanDatabase(); + + server.close(); +}); + +describe('Vendor product availability status management tests', () => { + it('Should update product availability status', async () => { + const response = await request(app) + .put(`/product/availability/${product1Id}`) + .send({ + isAvailable: false, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.message).toBe('Product status updated successfully'); + }, 10000); + + it('should auto update product status to false if product is expired', async () => { + const response = await request(app) + .put(`/product/availability/${product2Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(201); + expect(response.body.data.message).toBe('Product status is set to false because it is expired.'); + }); + + it('should update product status to false if product is out of stock', async () => { + const response = await request(app) + .put(`/product/availability/${product5Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(202); + expect(response.body.data.message).toBe('Product status is set to false because it is out of stock.'); + }); + + it('should not update product status if it is already updated', async () => { + const response = await request(app) + .put(`/product/availability/${product1Id}`) + .send({ + isAvailable: false, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(400); + }); + + it("should not update product status if it doesn't exists", async () => { + const response = await request(app) + .put(`/product/availability/${product4Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Product not found'); + }); + + it('should not update product which is not in VENDOR s stock', async () => { + const response = await request(app) + .put(`/product/availability/${product3Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Product not found in your stock'); + }); +}); + +describe('search product by name availability tests', () => { + it('Should search product by name', async () => { + const response = await request(app).get(`/product/search?name=testingmkknkkjiproduct4`); + expect(response.body.data).toBeDefined; + }, 10000); + + it('should return empty array if there is product is not found in the database', async () => { + const response = await request(app).put(`/product/search?name=home`); + + expect(response.statusCode).toBe(401); + expect(response.body.data).toBeUndefined; + }); +}); diff --git a/src/__test__/roleCheck.test.ts b/src/__test__/roleCheck.test.ts index 196066e..b17df32 100644 --- a/src/__test__/roleCheck.test.ts +++ b/src/__test__/roleCheck.test.ts @@ -5,6 +5,8 @@ import { responseError } from '../utils/response.utils'; import { dbConnection } from '../startups/dbConnection'; import { v4 as uuid } from 'uuid'; import { getConnection } from 'typeorm'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; +import { server } from '..'; let reqMock: Partial; let resMock: Partial; @@ -13,85 +15,79 @@ let nextMock: NextFunction; const userId = uuid(); beforeAll(async () => { - // Connect to the test database - const connection = await dbConnection(); + // Connect to the test database + const connection = await dbConnection(); - const userRepository = connection?.getRepository(User); + const userRepository = connection?.getRepository(User); - const user = new User(); + const user = new User(); - user.id = userId; - user.firstName = 'John2'; - user.lastName = 'Doe'; - user.email = 'john2.doe@example.com'; - user.password = 'password'; - user.gender = 'Male'; - user.phoneNumber = '1234'; - user.userType = 'Buyer'; - user.photoUrl = 'https://example.com/photo.jpg'; + user.id = userId; + user.firstName = 'John2'; + user.lastName = 'Doe'; + user.email = 'john2.doe@example.com'; + user.password = 'password'; + user.gender = 'Male'; + user.phoneNumber = '1234'; + user.userType = 'Buyer'; + user.photoUrl = 'https://example.com/photo.jpg'; - await userRepository?.save(user); + await userRepository?.save(user); }); afterAll(async () => { - const connection = getConnection(); - const userRepository = connection.getRepository(User); - - // Delete all records from the User - await userRepository.clear(); - - // Close the connection to the test database - await connection.close(); + await cleanDatabase(); + server.close(); }); describe('hasRole MiddleWare Test', () => { - beforeEach(() => { - reqMock = {}; - resMock = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - }; - nextMock = jest.fn(); - }); + beforeEach(() => { + reqMock = {}; + resMock = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + nextMock = jest.fn(); + }); - it('should return 401, if user is not authentication', async () => { - await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalled; - expect(resMock.status).toHaveBeenCalledWith(401); - }); + it('should return 401, if user is not authentication', async () => { + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(401); + }); - it('should return 401 if user is not found', async () => { - reqMock = { user: { id: uuid() } }; + it('should return 401 if user is not found', async () => { + reqMock = { user: { id: uuid() } }; - await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalled; - expect(resMock.status).toHaveBeenCalledWith(401); - }); + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(401); + }); - it('should return 403 if user does not have required role', async () => { - reqMock = { user: { id: userId } }; + it('should return 403 if user does not have required role', async () => { + reqMock = { user: { id: userId } }; - await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalled; - expect(resMock.status).toHaveBeenCalledWith(403); - }); + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(403); + }); - it('should call next() if user has required role', async () => { - reqMock = { user: { id: userId } }; + it('should call next() if user has required role', async () => { + reqMock = { user: { id: userId } }; - await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); + await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); - expect(nextMock).toHaveBeenCalled(); - }); + expect(nextMock).toHaveBeenCalled(); + }); - it('should return 400 if user id is of invalid format', async () => { - reqMock = { user: { id: 'sample userId' } }; + it('should return 400 if user id is of invalid format', async () => { + reqMock = { user: { id: 'sample userId' } }; - await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); + await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); - expect(responseError).toHaveBeenCalled; - expect(resMock.status).toHaveBeenCalledWith(400); - }); + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(400); + }); }); diff --git a/src/__test__/route.test.ts b/src/__test__/route.test.ts index d1c3be2..ac704b5 100644 --- a/src/__test__/route.test.ts +++ b/src/__test__/route.test.ts @@ -2,23 +2,16 @@ import request from 'supertest'; import { app, server } from '../index'; import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; import { User } from '../entities/User'; +import { response } from 'express'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; beforeAll(async () => { - // Connect to the test database - const connectionOptions = await getConnectionOptions(); - - await createConnection({ ...connectionOptions, name: 'testConnection' }); + await createConnection(); }); +jest.setTimeout(20000); afterAll(async () => { - const connection = getConnection('testConnection'); - const userRepository = connection.getRepository(User); - - // Delete all records from the User - await userRepository.clear(); - - // Close the connection to the test database - await connection.close(); + await cleanDatabase(); server.close(); }); @@ -26,7 +19,7 @@ afterAll(async () => { describe('GET /', () => { it('This is a testing route that returns', done => { request(app) - .get('/api/v1/status') + .get('/') .expect(200) .expect('Content-Type', /json/) .expect( @@ -106,9 +99,10 @@ describe('Send password reset link', () => { it('Attempt to send email with rate limiting', async () => { const email = 'elijahladdiedv@gmail.com'; - const requests = Array.from({ length: 5 }, async () => { - return await request(app).post(`/user/password/reset/link?email=${email}`); - }); + const requests = []; + for (let i = 0; i < 5; i++) { + requests.push(await request(app).post(`/user/password/reset/link?email=${email}`)); + } const responses = await Promise.all(requests); const lastResponse = responses[responses.length - 1]; @@ -194,3 +188,43 @@ describe('Password Reset Service', () => { } }); }); +describe('PUT/user/update', () => { + it('should return 401 if user is not authenticated', async () => { + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe23@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '12345678900', + userType: 'Buyer', + photoUrl: 'https://example.com/photo.jpg', + }; + + // Create a new user + const res = await request(app).post('/user/register').send(newUser); + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ where: { email: newUser.email } }); + if (user) { + const updateUser = { + id: user.id, + firstName: 'Biguseers2399', + lastName: '1', + email: 'john.doe23@example.com', + gender: 'Male', + phoneNumber: '0790easdas7dsdfd76175', + photoUrl: 'photo', + }; + const res = await request(app).put('/user/update').send(updateUser); + expect(res.status).toBe(201); + expect(res.body).toEqual({ + status: 'success', + data: { + code: 201, + message: 'User Profile has successfully been updated', + }, + }); + } + }); +}); diff --git a/src/__test__/searchProduct.test.ts b/src/__test__/searchProduct.test.ts new file mode 100644 index 0000000..081427c --- /dev/null +++ b/src/__test__/searchProduct.test.ts @@ -0,0 +1,163 @@ +import { Product } from '../entities/Product'; +import { app, server } from '../index'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +import { searchProductService } from '../services/productServices/searchProduct'; + +const vendor1Id = uuid(); +const vendor2Id = uuid(); +const buyerId = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const product3Id = uuid(); +const catId = uuid(); + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1o', + lastName: 'user', + email: 'vendor10@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'vendor2o', + lastName: 'user', + email: 'vendor20@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Female', + phoneNumber: '1234567890', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleBuyer1: UserInterface = { + id: buyerId, + firstName: 'buyer1o', + lastName: 'user', + email: 'buyer10@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '000380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleCat: Category = { + id: catId, + name: 'accessories', +} as Category; + +const sampleProduct1: Product = { + id: product1Id, + name: 'Product A', + description: 'Amazing product A', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 100, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +} as Product; + +const sampleProduct2: Product = { + id: product2Id, + name: 'Product B', + description: 'Amazing product B', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 20, + vendor: sampleVendor1, + categories: [sampleCat], +} as Product; + +const sampleProduct3: Product = { + id: product3Id, + name: 'Product C', + description: 'Amazing product C', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 300, + quantity: 30, + vendor: sampleVendor2, + categories: [sampleCat], +} as Product; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save(sampleCat); + + const userRepository = connection?.getRepository(User); + await userRepository?.save(sampleVendor1); + await userRepository?.save(sampleVendor2); + await userRepository?.save(sampleBuyer1); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save(sampleProduct1); + await productRepository?.save(sampleProduct2); + await productRepository?.save(sampleProduct3); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); + +describe('searchProductService', () => { + it('should return all products without filters', async () => { + const result = await searchProductService({}); + expect(result.data.length).toBe(3); + expect(result.pagination.totalItems).toBe(3); + expect(result.pagination.totalPages).toBe(1); + }); + + it('should return products matching the name filter', async () => { + const result = await searchProductService({ name: 'Product A' }); + expect(result.data.length).toBe(1); + expect(result.data[0].name).toBe('Product A'); + expect(result.pagination.totalItems).toBe(1); + expect(result.pagination.totalPages).toBe(1); + }); + + it('should return sorted products by price in descending order', async () => { + const result = await searchProductService({ sortBy: 'newPrice', sortOrder: 'DESC' }); + expect(result.data.length).toBe(3); + expect(result.data[0].newPrice).toBe("300"); + expect(result.data[1].newPrice).toBe("200"); + expect(result.data[2].newPrice).toBe("100"); + }); + + it('should return paginated results', async () => { + const result = await searchProductService({ page: 1, limit: 2 }); + expect(result.data.length).toBe(2); + expect(result.pagination.totalItems).toBe(3); + expect(result.pagination.totalPages).toBe(2); + + const resultPage2 = await searchProductService({ page: 2, limit: 2 }); + expect(resultPage2.data.length).toBe(1); + expect(resultPage2.pagination.currentPage).toBe(2); + }); + + it('should handle sorting and pagination together', async () => { + const result = await searchProductService({ sortBy: 'newPrice', sortOrder: 'ASC', page: 1, limit: 2 }); + expect(result.data.length).toBe(2); + expect(result.data[0].newPrice).toBe("100"); + expect(result.data[1].newPrice).toBe("200"); + + const resultPage2 = await searchProductService({ sortBy: 'newPrice', sortOrder: 'ASC', page: 2, limit: 2 }); + expect(resultPage2.data.length).toBe(1); + expect(resultPage2.data[0].newPrice).toBe("300"); + }); +}); diff --git a/src/__test__/test-assets/DatabaseCleanup.ts b/src/__test__/test-assets/DatabaseCleanup.ts new file mode 100644 index 0000000..1e86ca0 --- /dev/null +++ b/src/__test__/test-assets/DatabaseCleanup.ts @@ -0,0 +1,52 @@ +import { Transaction } from '../../entities/transaction'; +import { Cart } from '../../entities/Cart'; +import { CartItem } from '../../entities/CartItem'; +import { Order } from '../../entities/Order'; +import { OrderItem } from '../../entities/OrderItem'; +import { wishList } from '../../entities/wishList'; +import { getConnection } from 'typeorm'; +import { Product } from '../../entities/Product'; +import { Category } from '../../entities/Category'; +import { Coupon } from '../../entities/coupon'; +import { User } from '../../entities/User'; +import { server } from '../..'; +import { VendorOrderItem } from '../../entities/VendorOrderItem'; +import { VendorOrders } from '../../entities/vendorOrders'; +import { Feedback } from '../../entities/Feedback'; + +export const cleanDatabase = async () => { + const connection = getConnection(); + + // Delete from child tables first + await connection.getRepository(Feedback).delete({}); + await connection.getRepository(Transaction).delete({}); + await connection.getRepository(Coupon).delete({}); + await connection.getRepository(VendorOrderItem).delete({}); + await connection.getRepository(VendorOrders).delete({}); + await connection.getRepository(OrderItem).delete({}); + await connection.getRepository(Order).delete({}); + await connection.getRepository(CartItem).delete({}); + await connection.getRepository(Cart).delete({}); + await connection.getRepository(wishList).delete({}); + + // Many-to-Many relations + // Clear junction table entries before deleting products and categories + await connection.createQueryRunner().query('DELETE FROM product_categories_category'); + + await connection.getRepository(Product).delete({}); + await connection.getRepository(Category).delete({}); + + // Coupons (if related to Orders or Users) + + // Finally, delete from parent table + await connection.getRepository(User).delete({}); + + await connection.close(); +}; + +// // Execute the clean-up function +// cleanDatabase().then(() => { +// console.log('Database cleaned'); +// }).catch(error => { +// console.error('Error cleaning database:', error); +// }); \ No newline at end of file diff --git a/src/__test__/test-assets/photo1.png b/src/__test__/test-assets/photo1.png new file mode 100644 index 0000000000000000000000000000000000000000..14aacbc37b4dbf9aae0e544691b42b2260cbb896 GIT binary patch literal 42194 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 o33UFjar!{uKkk~5G5?vkYe*5fUODT` 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 +let orderRepository: Repository; +let transactionRepository: Repository; + +beforeAll(async () => { + const connection = await dbConnection(); + if (!connection) { + console.error('Failed to connect to the database'); + return; + } + + userRepository = connection.getRepository(User); + orderRepository = connection.getRepository(Order); + transactionRepository = connection.getRepository(Transaction); + + const categoryRepository = connection.getRepository(Category); + await categoryRepository.save(sampleCat); + + await userRepository.save([sampleAdmin, sampleVendor, sampleVendor2, sampleBuyer]); + + const productRepository = connection.getRepository(Product); + await productRepository.save(sampleProduct); + + await orderRepository.save(sampleOrder); + + const orderItemRepository = connection.getRepository(OrderItem); + await orderItemRepository.save(sampleOrderItem); + + const vendorOrderRepository = connection.getRepository(VendorOrders); + await vendorOrderRepository.save(sampleVendorOrder); + + const vendorOrderItemRepository = connection.getRepository(VendorOrderItem); + await vendorOrderItemRepository.save(sampleVendorOrderItem); +}); + +afterAll(async () => { + await cleanDatabase(); + const connection = getConnection(); + if (connection.isConnected) { + await connection.close(); + } + server.close(); +}); + +describe('User Entity', () => { + it('should validate a valid user', async () => { + const user = userRepository.create({ + id: uuid(), + firstName: 'John', + lastName: 'Doe', + email: process.env.TEST_SAMPLE_BUYER_EMAIL, + password: process.env.TEST_USER_PASS, + gender: 'male', + phoneNumber: '1234567890', + verified: true, + status: 'active', + userType: 'Buyer', + twoFactorEnabled: false, + accountBalance: 0.0, + }); + + const errors = await validate(user); + expect(errors.length).toBe(0); + + const savedUser = await userRepository.save(user); + expect(savedUser.id).toBeDefined(); + expect(savedUser.createdAt).toBeDefined(); + expect(savedUser.updatedAt).toBeDefined(); + }); + + it('should not validate a user with missing required fields', async () => { + const user = new User(); + + const errors = await validate(user); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should set the role based on userType', async () => { + const user = userRepository.create({ + id: vendorOrder2Id, + firstName: 'Jane', + lastName: 'Doe', + email: process.env.TEST_VENDOR2_EMAIL, + password: process.env.TEST_USER_PASS, + gender: 'female', + phoneNumber: '0987654321', + userType: 'Vendor', + }); + + await userRepository.save(user); + expect(user.role).toBe('VENDOR'); + }); + + it('should handle relationships correctly', async () => { + const user = userRepository.create({ + id: uuid(), + firstName: 'Alice', + lastName: 'Smith', + email: 'alice.smith@example.com', + password: process.env.TEST_USER_PASS, + gender: 'female', + phoneNumber: '1122334455', + userType: 'Buyer', + }); + + const savedUser = await userRepository.save(user); + + const order = orderRepository.create({ + id: order2Id, + totalPrice: 400, + quantity: 2, + orderDate: new Date(), + buyer: savedUser, + orderStatus: 'order placed', + address: 'Rwanda, Kigali, KK20st', + }); + + const savedOrder = await orderRepository.save(order); + + const transaction = transactionRepository.create({ + id: uuid(), + order: savedOrder, + user: savedUser, + product: sampleProduct, + amount: 400, + previousBalance: 0, + currentBalance: 400, + type: 'credit', + description: 'order placed', + }); + + await transactionRepository.save(transaction); + + const foundUser = await userRepository.findOne({ + where: { id: savedUser.id }, + relations: ['orders', 'transactions'], + }); + + expect(foundUser).toBeDefined(); + expect(foundUser?.orders.length).toBe(1); + expect(foundUser?.transactions.length).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/__test__/userServices.test.ts b/src/__test__/userServices.test.ts index 5e435d1..29a2e7c 100644 --- a/src/__test__/userServices.test.ts +++ b/src/__test__/userServices.test.ts @@ -1,25 +1,15 @@ import request from 'supertest'; import { app, server } from '../index'; -import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { createConnection, getRepository } from 'typeorm'; import { User } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; beforeAll(async () => { - // Connect to the test database - const connectionOptions = await getConnectionOptions(); - - await createConnection({ ...connectionOptions, name: 'testConnection' }); + await createConnection(); }); afterAll(async () => { - const connection = getConnection('testConnection'); - const userRepository = connection.getRepository(User); - - // Delete all records from the User - await userRepository.clear(); - - // Close the connection to the test database - await connection.close(); - + await cleanDatabase(); server.close(); }); diff --git a/src/__test__/userStatus.test.ts b/src/__test__/userStatus.test.ts index f253551..a2e0732 100644 --- a/src/__test__/userStatus.test.ts +++ b/src/__test__/userStatus.test.ts @@ -6,6 +6,7 @@ import { getConnection } from 'typeorm'; import { dbConnection } from '../startups/dbConnection'; import { User } from '../entities/User'; import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; const adminUserId = uuid(); @@ -35,11 +36,8 @@ beforeAll(async () => { }); afterAll(async () => { - const connection = getConnection(); - const userRepository = connection.getRepository(User); + await cleanDatabase(); - // Close the connection to the test database - await connection.close(); server.close(); }); @@ -70,7 +68,7 @@ describe('POST /user/deactivate', () => { .send({ email: `${testUser.email}` }); expect(response.status).toBe(200); expect(response.body.message).toBe('User deactivated successfully'); - }, 10000); + }, 60000); it('should return 404 when email is not submitted', async () => { const token = jwt.sign(data, jwtSecretKey); @@ -113,7 +111,7 @@ describe('POST /user/activate', () => { expect(response.status).toBe(200); expect(response.body.message).toBe('User activated successfully'); - }, 10000); + }, 60000); it('should return 404 when email is not submitted', async () => { const token = jwt.sign(data, jwtSecretKey); @@ -150,4 +148,4 @@ describe('POST /user/activate', () => { expect(response.status).toBe(404); expect(response.body.error).toBe('User not found'); }); -}); +}); \ No newline at end of file diff --git a/src/__test__/vendorProduct.test.ts b/src/__test__/vendorProduct.test.ts new file mode 100644 index 0000000..f0a1450 --- /dev/null +++ b/src/__test__/vendorProduct.test.ts @@ -0,0 +1,473 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const vendor2Id = uuid(); +const buyer1Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendor1@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'vendor2', + lastName: 'user', + email: 'vendor2@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '1638099634', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleProduct2 = { + id: product2Id, + name: 'test product2', + description: 'amazing product2', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1 }); + await userRepository?.save({ ...sampleVendor2 }); + await userRepository?.save({ ...sampleBuyer1 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); + await productRepository?.save({ ...sampleProduct2 }); +}); + +afterAll(async () => { + await cleanDatabase(); + + server.close(); +}); + +describe('Vendor product management tests', () => { + describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 120000); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .post(`/product/`) + .field('name', 'test-product-images') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should not create new product it already exist', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(409); + }); + + it('should not create new product, if there are missing field data', async () => { + const response = await request(app) + .post('/product') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('should not create new product, images are not at least more than 1', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test-product-image') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Updating existing product', () => { + it('return error, if there are missing field data', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('return error, if product do not exist', async () => { + const response = await request(app) + .put(`/product/${uuid()}`) + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Product not found'); + }); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 0) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should update the product', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('name', 'test product3 updated') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('oldPrice', 100) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'tech') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + }); + + describe('Retrieving all vendor product', () => { + it('should retrieve all product belong to logged vendor', async () => { + const response = await request(app) + .get('/product/collection') + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeDefined; + }); + + it('should not return any product for a vendor with zero product in stock', async () => { + const response = await request(app) + .get(`/product/collection`) + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/collection?page=sdfsd`) + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(400); + expect(response.body).toBeUndefined; + }); + }); + + describe('Retrieving single vendor product', () => { + it('should retrieve single product for the user', async () => { + const response = await request(app) + .get(`/product/collection/${product1Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.product).toBeDefined; + }); + + it('should not return any product if product1Id do not exist', async () => { + const response = await request(app) + .get(`/product/collection/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.product).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/collection/id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.product).toBeUndefined; + }); + }); + + describe('Removing product image', () => { + it('should remove one image', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: sampleProduct1.images[2], + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + + it('return error, if no image to remove provided', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Please provide an image to remove'); + }); + + it("return error, if product doesn't exist", async () => { + const response = await request(app) + .delete(`/product/images/${uuid()}`) + .send({ + image: sampleProduct1.images[2], + }) + + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Product not found'); + }); + + it('return error, if product has only 2 images', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: sampleProduct1.images[0], + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product must have at least two image'); + }); + + it("return error, if image to remove deosn't exist", async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: 'image', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Image not found'); + }); + }); + + describe('Deleting a vendor product', () => { + it('should delete a product for the vendor', async () => { + const response = await request(app) + .delete(`/product/${product2Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + + it('should return error for non existing products', async () => { + const response = await request(app) + .delete(`/product/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + }); + + it('should return error for invalid input syntax', async () => { + const response = await request(app) + .delete(`/product/product2Id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Retrieving recommended products', () => { + it('should retrieve products', async () => { + const response = await request(app) + .get('/product/recommended') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined; + }); + + it('should not return any product for a vendor with zero product in stock', async () => { + const response = await request(app) + .get(`/product/recommended`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/recommended?page=sdfsd`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body).toBeUndefined; + }); + }); + + describe('List all products service', () => { + it('should return all products for a given category', async () => { + const response = await request(app).get('/product/all'); + + expect(response.status).toBe(200); + expect(response.body.data.products).toBeDefined(); + }); + + it('should return no products for a non-existent category', async () => { + const response = await request(app) + .get('/product/all') + .query({ page: 1, limit: 10, category: 'nonexistentcategory' }); + + expect(response.status).toBe(200); + expect(response.body.data.products).toBeUndefined(); + }); + + it('should return an error for invalid input syntax', async () => { + const response = await request(app) + .get('/product/all') + .query({ page: 'invalid', limit: 'limit', category: 'technology' }); + + expect(response.status).toBe(400); + }); + }); +}); \ No newline at end of file diff --git a/src/__test__/wishList.test.ts b/src/__test__/wishList.test.ts new file mode 100644 index 0000000..6658853 --- /dev/null +++ b/src/__test__/wishList.test.ts @@ -0,0 +1,204 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { wishList } from '../entities/wishList'; +import { User, UserInterface } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const buyer1Id = uuid(); +const buyer2Id = uuid(); +let product1Id: string; +let product2Id: string; +const catId = uuid(); +const vendor2Id = uuid(); + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const sampleBuyer2: UserInterface = { + id: buyer2Id, + firstName: 'buyer2', + lastName: 'use', + email: 'buyer2@example.com', + password: 'passwo', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '1638099347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const sampleVendor1: UserInterface = { + id: vendor2Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendor11@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '12638090347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +let productInWishList: number; + +beforeAll(async () => { + const connection = await dbConnection(); + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleBuyer1 }); + await userRepository?.save({ ...sampleBuyer2 }); + await userRepository?.save({ ...sampleVendor1 }); +}); + +afterAll(async () => { + await cleanDatabase(); + server.close(); +}); +const data1 = { + id: buyer1Id, + email: sampleBuyer1.email, +}; +const data2 = { + id: buyer2Id, + email: sampleBuyer2.email, +}; +const vendorData = { + id: vendor2Id, + email: sampleVendor1.email, +}; + +const jwtSecretKey = process.env.JWT_SECRET || ''; +describe('Wish list management tests', () => { + describe('Add product to wish list', () => { + it('should return 404 when product is not found', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).post(`/wish-list/add/${uuid()}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'Product not found' }); + }); + + it('should add a new product to wish list', async () => { + const vendorToken = jwt.sign(vendorData, jwtSecretKey); + const prod1Response = await request(app) + .post('/product') + .field('name', 'test product12') + .field('description', 'amazing product3') + .field('newPrice', 2000) + .field('quantity', 10) + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${vendorToken}`); + + product1Id = prod1Response.body.data.product.id; + + const prod2Response = await request(app) + .post('/product') + .field('name', 'more product2') + .field('description', 'food product3') + .field('newPrice', 2000) + .field('quantity', 10) + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${vendorToken}`); + + product2Id = prod2Response.body.data.product.id; + + const token = jwt.sign(data1, jwtSecretKey); + const response1 = await request(app).post(`/wish-list/add/${product1Id}`).set('Authorization', `Bearer ${token}`); + expect(response1.status).toBe(201); + expect(response1.body.data.message).toBe('Product Added to wish list'); + productInWishList = response1.body.data.wishlistAdded.id; + + await request(app).post(`/wish-list/add/${product2Id}`).set('Authorization', `Bearer ${token}`); + }); + + it('should tell if there is the product is already in the wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).post(`/wish-list/add/${product1Id}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(401); + expect(response.body.data.message).toBe('Product Already in the wish list'); + }); + it('should return 500 when the ID is not valid', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .post(`/wish-list/add/kjwxq-wbjk2-2bwqs-21`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(500); + }); + }); + + describe('Get products in wishList', () => { + it('Returns 404 when buyer has no product in wish list', async () => { + const token = jwt.sign(data2, jwtSecretKey); + const response = await request(app).get('/wish-list').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('No products in wish list'); + }); + + it('Returns products in the wish list for a buyer ', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).get('/wish-list').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Products retrieved'); + }); + }); + + describe('Remove a product from wish lsit', () => { + it('should return 404 when product is not found in wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).delete(`/wish-list/delete/${28}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found in wish list'); + }); + + it('should delete a product from wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .delete(`/wish-list/delete/${productInWishList}`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Product removed from wish list'); + }); + it('should return 500 when the ID is not valid', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .delete(`/wish-list/delete/kjwxq-wbjk2-2bwqs-21`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(500); + }); + }); + + describe('Clear all products in wish for a user', () => { + it('Returns 404 when buyer has no product in wish list', async () => { + const token = jwt.sign(data2, jwtSecretKey); + const response = await request(app).delete('/wish-list/clearAll').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('No products in wish list'); + }); + + it('should delete all products for a nuyer in wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).delete('/wish-list/clearAll').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('All products removed successfully'); + }); + }); +}); diff --git a/src/controllers/adminOrdercontroller.ts b/src/controllers/adminOrdercontroller.ts new file mode 100644 index 0000000..388220d --- /dev/null +++ b/src/controllers/adminOrdercontroller.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { + getSingleBuyerVendorOrderService, + getBuyerVendorOrdersService, + updateBuyerVendorOrderService, +} from '../services'; + +export const getBuyerVendorOrders = async (req: Request, res: Response) => { + await getBuyerVendorOrdersService(req, res); +}; + +export const getSingleBuyerVendorOrder = async (req: Request, res: Response) => { + await getSingleBuyerVendorOrderService(req, res); +}; + +export const updateBuyerVendorOrder = async (req: Request, res: Response) => { + await updateBuyerVendorOrderService(req, res); +}; diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index fbc025d..a66cecf 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -13,6 +13,7 @@ import { userPasswordResetService } from '../services/userServices/userPasswordR import { sendPasswordResetLinkService } from '../services/userServices/sendResetPasswordLinkService'; import { activateUserService } from '../services/updateUserStatus/activateUserService'; import { deactivateUserService } from '../services/updateUserStatus/deactivateUserService'; +import { userProfileUpdateServices } from '../services/userServices/userProfileUpdateServices'; export const userRegistration = async (req: Request, res: Response) => { await userRegistrationService(req, res); @@ -52,14 +53,17 @@ export const sendPasswordResetLink = async (req: Request, res: Response) => { await sendPasswordResetLinkService(req, res); }; -export async function activateUser(req: Request, res: Response) { +export async function activateUser (req: Request, res: Response) { await activateUserService(req, res); } -export async function disactivateUser(req: Request, res: Response) { +export async function disactivateUser (req: Request, res: Response) { await deactivateUserService(req, res); } export const logout = async (req: Request, res: Response) => { await logoutService(req, res); }; +export const userProfileUpdate = async (req: Request, res: Response) => { + await userProfileUpdateServices(req, res); +}; diff --git a/src/controllers/cartController.ts b/src/controllers/cartController.ts new file mode 100644 index 0000000..3411103 --- /dev/null +++ b/src/controllers/cartController.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { createCartService, readCartService, removeProductInCartService, clearCartService } from '../services'; + +export const createCart = async (req: Request, res: Response) => { + await createCartService(req, res); +}; + +export const readCart = async (req: Request, res: Response) => { + await readCartService(req, res); +}; + +export const removeProductInCart = async (req: Request, res: Response) => { + await removeProductInCartService(req, res); +}; + +export const clearCart = async (req: Request, res: Response) => { + await clearCartService(req, res); +}; diff --git a/src/controllers/chatBotController.ts b/src/controllers/chatBotController.ts new file mode 100644 index 0000000..5c3d366 --- /dev/null +++ b/src/controllers/chatBotController.ts @@ -0,0 +1,6 @@ +import { Request, Response } from 'express'; +import { chatBot } from '../services'; + +export const chatBotController = async (req: Request, res: Response) => { + await chatBot(req, res); +}; diff --git a/src/controllers/couponController.ts b/src/controllers/couponController.ts new file mode 100644 index 0000000..e5a6804 --- /dev/null +++ b/src/controllers/couponController.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { createCouponService } from '../services/couponServices/createCouponService'; +import { updateCouponService } from '../services/couponServices/updateService'; +import { deleteCouponService } from '../services/couponServices/deleteCoupon'; +import { accessAllCouponService } from '../services/couponServices/accessAllCoupon'; +import { readCouponService } from '../services/couponServices/readCoupon'; +import { buyerApplyCouponService } from '../services/couponServices/buyerApplyCoupon'; + +export const createCoupon = async (req: Request, res: Response) => { + await createCouponService(req, res); +}; + +export const updateCoupon = async (req: Request, res: Response) => { + await updateCouponService(req, res); +}; + +export const deleteCoupon = async (req: Request, res: Response) => { + await deleteCouponService(req, res); +}; + +export const accessAllCoupon = async (req: Request, res: Response) => { + await accessAllCouponService(req, res); +}; + +export const readCoupon = async (req: Request, res: Response) => { + await readCouponService(req, res); +}; + +export const buyerApplyCoupon = async (req: Request, res: Response) => { + await buyerApplyCouponService(req, res); +}; diff --git a/src/controllers/feedbackController.ts b/src/controllers/feedbackController.ts new file mode 100644 index 0000000..0cbce14 --- /dev/null +++ b/src/controllers/feedbackController.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { createFeedbackService } from '../services/feedbackServices/createFeedback'; +import { updateFeedbackService } from '../services/feedbackServices/updateFeedback'; +import { deleteFeedbackService } from '../services/feedbackServices/deleteFeedback'; +import { adminDeleteFeedbackService } from '../services/feedbackServices/adminDeleteFeedback'; + +export const createFeedback = async (req: Request, res: Response) => { + await createFeedbackService(req, res); +}; + +export const updateFeedback = async (req: Request, res: Response) => { + await updateFeedbackService(req, res); +}; + +export const deleteFeedback = async (req: Request, res: Response) => { + await deleteFeedbackService(req, res); +}; + +export const adminDeleteFeedback = async (req: Request, res: Response) => { + await adminDeleteFeedbackService(req, res); +}; diff --git a/src/controllers/index.ts b/src/controllers/index.ts index a17886d..adaf1d1 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1 +1,6 @@ -export * from './authController'; \ No newline at end of file +export * from './authController'; +export * from './productController'; +export * from './orderController'; +export * from './vendorOrderController'; +export * from './adminOrdercontroller'; +export * from './chatBotController'; \ No newline at end of file diff --git a/src/controllers/notificationControllers.ts b/src/controllers/notificationControllers.ts new file mode 100644 index 0000000..1255efd --- /dev/null +++ b/src/controllers/notificationControllers.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { getNotificationsService, updateNotificationsService, deleteSelectedNotificationService, deleteAllNotificationService, updateAllNotificationsService } from '../services'; + +export const getAllNotifications = async (req: Request, res: Response) => { + await getNotificationsService(req, res); +}; + +export const deleteSelectedNotifications = async (req: Request, res: Response) => { + await deleteSelectedNotificationService(req, res); +}; + +export const deleteAllNotifications = async (req: Request, res: Response) => { + await deleteAllNotificationService(req, res); +}; + +export const updateNotifications = async (req: Request, res: Response) => { + await updateNotificationsService(req, res); +}; + +export const updateAllNotifications = async (req: Request, res: Response) => { + await updateAllNotificationsService(req, res); +}; \ No newline at end of file diff --git a/src/controllers/orderController.ts b/src/controllers/orderController.ts new file mode 100644 index 0000000..7a877bb --- /dev/null +++ b/src/controllers/orderController.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { createOrderService } from '../services/orderServices/createOrder'; +import { getOrderService, getOrdersService } from '../services/orderServices/getOrderService'; +import { updateOrderService } from '../services/orderServices/updateOrderService'; +import { getTransactionHistoryService } from '../services/orderServices/getOrderTransactionHistory'; + +export const createOrder = async (req: Request, res: Response) => { + await createOrderService(req, res); +}; +export const getOrders = async (req: Request, res: Response) => { + await getOrdersService(req, res); +}; + +export const getOrder = async (req: Request, res: Response) => { + await getOrderService(req, res); +}; +export const updateOrder = async (req: Request, res: Response) => { + await updateOrderService(req, res); +}; +export const getOrdersHistory = async (req: Request, res: Response) => { + await getTransactionHistoryService(req, res); +}; \ No newline at end of file diff --git a/src/controllers/productController.ts b/src/controllers/productController.ts new file mode 100644 index 0000000..c68e16b --- /dev/null +++ b/src/controllers/productController.ts @@ -0,0 +1,76 @@ +import { Request, Response } from 'express'; +import { + createProductService, + updateProductService, + removeProductImageService, + readProductService, + readProductsService, + deleteProductService, + getRecommendedProductsService, + productStatusServices, + viewSingleProduct, + searchProductService, + listAllProductsService, + confirmPayment, +} from '../services'; + +export const readProduct = async (req: Request, res: Response) => { + await readProductService(req, res); +}; + +export const readProducts = async (req: Request, res: Response) => { + await readProductsService(req, res); +}; + +export const createProduct = async (req: Request, res: Response) => { + await createProductService(req, res); +}; + +export const updateProduct = async (req: Request, res: Response) => { + await updateProductService(req, res); +}; + +export const removeProductImage = async (req: Request, res: Response) => { + await removeProductImageService(req, res); +}; + +export const deleteProduct = async (req: Request, res: Response) => { + await deleteProductService(req, res); +}; + +export const getRecommendedProducts = async (req: Request, res: Response) => { + await getRecommendedProductsService(req, res); +}; + +export const listAllProducts = async (req: Request, res: Response) => { + await listAllProductsService(req, res); +}; +export const productStatus = async (req: Request, res: Response) => { + await productStatusServices(req, res); +}; +export const singleProduct = async (req: Request, res: Response) => { + await viewSingleProduct(req, res); +}; +export const searchProduct = async (req: Request, res: Response) => { + const { name, sortBy, sortOrder, page, limit } = req.query; + + try { + const searchParams = { + name: name as string, + sortBy: sortBy as string, + sortOrder: sortOrder as 'ASC' | 'DESC', + page: parseInt(page as string, 10) || 1, + limit: parseInt(limit as string, 10) || 10, + }; + + const result = await searchProductService(searchParams); + + res.json(result); + } catch (error) { + console.error('Error searching products:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}; +export const Payment = async (req: Request, res: Response) => { + await confirmPayment(req, res); +}; \ No newline at end of file diff --git a/src/controllers/vendorOrderController.ts b/src/controllers/vendorOrderController.ts new file mode 100644 index 0000000..955b01c --- /dev/null +++ b/src/controllers/vendorOrderController.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import { getVendorOrdersService, getSingleVendorOrderService, updateVendorOrderService } from '../services'; + +export const getVendorOrders = async (req: Request, res: Response) => { + await getVendorOrdersService(req, res); +}; + +export const getSingleVendorOrder = async (req: Request, res: Response) => { + await getSingleVendorOrderService(req, res); +}; + +export const updateVendorOrder = async (req: Request, res: Response) => { + await updateVendorOrderService(req, res); +}; diff --git a/src/controllers/wishListController.ts b/src/controllers/wishListController.ts new file mode 100644 index 0000000..23fa03f --- /dev/null +++ b/src/controllers/wishListController.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { addProductService, getProductsService, removeProductService, clearAllProductService } from '../services'; + +export const wishlistAddProduct = async (req: Request, res: Response) => { + await addProductService(req, res); +}; + +export const wishlistRemoveProduct = async (req: Request, res: Response) => { + await removeProductService(req, res); +}; + +export const wishlistGetProducts = async (req: Request, res: Response) => { + await getProductsService(req, res); +}; + +export const wishlistClearAllProducts = async (req: Request, res: Response) => { + await clearAllProductService(req, res); +}; diff --git a/src/docs/adminOrderManagement.yml b/src/docs/adminOrderManagement.yml new file mode 100644 index 0000000..e8d6ed6 --- /dev/null +++ b/src/docs/adminOrderManagement.yml @@ -0,0 +1,80 @@ +/product/admin/orders: + get: + tags: + - Admin Order Manangement + summary: Fetches all buyer and vendor orders + description: Return all order including details for buyer and vendors of products in that order + security: + - bearerAuth: [] + responses: + '200': + description: Return all order + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/product/admin/orders/{id}: + get: + tags: + - Admin Order Manangement + summary: Fetch details for single buyer and vendor order + description: + Fetch details for single order using buyer id, if successful return order details with it's corresponding vendor + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of a buyer order + responses: + '200': + description: Order details retrieved successfully + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Order not found + '500': + description: Internal server error + put: + tags: + - Admin Order Manangement + summary: Updates order status for both buyer and vendor order + description: Updates orderStatus field of order, if successful returns updated order. + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of a buyer order + consumes: + - application/json + responses: + '200': + description: Order was successfully updated, return updated order + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Order not found + '409': + description: Order can not be updated because (it has already been completed(close), delivered, cancelled) + '500': + description: Internal server error diff --git a/src/docs/authDocs.yml b/src/docs/authDocs.yml index 805cc53..fa69d1e 100644 --- a/src/docs/authDocs.yml +++ b/src/docs/authDocs.yml @@ -130,7 +130,7 @@ '500': description: Internal server error -/user//resend-otp: +/user/resend-otp: post: tags: - Auth diff --git a/src/docs/cartDocs.yml b/src/docs/cartDocs.yml new file mode 100644 index 0000000..9962129 --- /dev/null +++ b/src/docs/cartDocs.yml @@ -0,0 +1,103 @@ +/cart: + get: + tags: + - Cart + summary: Get all products in cart + description: Return all products in cart for either guest user or authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Return all products in cart for the user or return empty cart if no product available + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + + post: + tags: + - Cart + summary: Add product to cart or updates its quantity + description: Add product to cart or updates its quantity for either guest user or authenticated user + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + productId: + type: string + description: The id of product + quantity: + type: integer + description: The quantity of product + responses: + '200': + description: Product added to cart + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + + delete: + tags: + - Cart + summary: Clear entire cart + description: Clears entire cart for either guest user or authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Cart cleared + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/cart/{id}: + delete: + tags: + - Cart + summary: Remove cart item from cart + description: Remove cart item from cart for either guest user or authenticated user + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of cart item + responses: + '200': + description: Product removed from cart + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error diff --git a/src/docs/couponDocs.yml b/src/docs/couponDocs.yml new file mode 100644 index 0000000..fefa829 --- /dev/null +++ b/src/docs/couponDocs.yml @@ -0,0 +1,217 @@ +/coupons/vendor/:id/access-coupons: + get: + tags: + - Vendor discount coupon management + summary: List all coupons + description: Return all coupons for the logged user + security: + - bearerAuth: [] + responses: + '200': + description: Return all coupons + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/coupons/vendor/:id/checkout/:code: + get: + tags: + - Vendor discount coupon management + summary: Get a single coupon + description: Return a coupon based on the provided code + security: + - bearerAuth: [] + parameters: + - in: path + name: code + schema: + type: string + required: true + description: The code of the coupon + responses: + '200': + description: Return info for the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/coupons/vendor/:id: + post: + tags: + - Vendor discount coupon management + summary: Creates a new coupon + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + discountType: + type: string + discountRate: + type: number + maxUsageLimit: + type: number + quantity: + type: number + product: + type: string + expirationDate: + type: string + format: date + required: + - code + - discountType + - maxUsageLimit + - product + responses: + '201': + description: Successfully added the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/coupons/vendor/:id/update-coupon/:code: + put: + tags: + - Vendor discount coupon management + summary: Update a coupon + security: + - bearerAuth: [] + parameters: + - in: path + name: code + schema: + type: string + required: true + description: The code of the coupon + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + discountType: + type: string + discountRate: + type: number + maxUsageLimit: + type: number + quantity: + type: number + product: + type: string + expirationDate: + type: string + format: date + responses: + '200': + description: Successfully updated the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/vendor/:id/checkout/delete: + delete: + tags: + - Vendor discount coupon management + summary: Delete a coupon + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The ID of the vendor + - in: query + name: code + schema: + type: string + required: true + description: The code of the coupon + responses: + '200': + description: Successfully deleted the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/apply: + post: + tags: + - Buyer Coupon Discount Management + summary: Give discount according to coupon code + description: Buyer gets discount on a product when all the checks pass + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + couponCode: + type: string + required: + - couponCode + responses: + '200': + description: Successfully Got Discount + '400': + description: Bad Request (Syntax error, No coupon code provide, Coupon is expired, Coupon Discount Ended,etc) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found, No cart or product with that coupon is not in cart + '500': + description: Internal server error diff --git a/src/docs/orderDocs.yml b/src/docs/orderDocs.yml new file mode 100644 index 0000000..fcb620e --- /dev/null +++ b/src/docs/orderDocs.yml @@ -0,0 +1,108 @@ +paths: + /product/orders: + post: + tags: + - Order + summary: Make an order + description: Create a new order for the authenticated user + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + address: + type: object + properties: + country: + type: string + description: The country of the shipping address + city: + type: string + description: The city of the shipping address + street: + type: string + description: The street address + required: + - address + responses: + '201': + description: Order created successfully + '400': + description: Bad Request + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/client/orders: + get: + tags: + - Order + summary: Get all orders + description: Retrieve all orders for the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Orders retrieved successfully + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/orders/history: + get: + tags: + - Order + summary: Get transaction history + description: Retrieve transaction history for the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Transaction history retrieved successfully + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/client/orders/:orderId: + put: + tags: + - Order + summary: Update order status + description: Update the status of a specific order for the authenticated user + security: + - bearerAuth: [] + parameters: + - in: path + name: orderId + schema: + type: string + required: true + description: The ID of the order + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + orderStatus: + type: string + description: The new status of the order + responses: + '200': + description: Order updated successfully + '400': + description: Bad Request + '401': + description: Unauthorized + '404': + description: Order not found + '500': + description: Internal Server Error diff --git a/src/docs/vendorOrderManagement.yml b/src/docs/vendorOrderManagement.yml new file mode 100644 index 0000000..5873717 --- /dev/null +++ b/src/docs/vendorOrderManagement.yml @@ -0,0 +1,93 @@ +/product/vendor/orders: + get: + tags: + - Vendor Order Manangement + summary: Fetches all vendor orders + description: Return all order for authenticated vendor + security: + - bearerAuth: [] + responses: + '200': + description: Return all order for vendor requested from buyer + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/product/vendor/orders/{id}: + get: + tags: + - Vendor Order Manangement + summary: Fetch details for single vendor order + description: + Fetch details for single order for authenticated vendor, order that include only his/her product which a buyer has + requested in his order. + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of a vendor order + responses: + '200': + description: Order details retrieved successfully + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Order not found + '500': + description: Internal server error + put: + tags: + - Vendor Order Manangement + summary: Updates order status for vendor order + description: + Updates orderStatus field of vendor order for authenticated vendor, it order that include only his/her product + which a buyer has request in his order. + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of a vendor order + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + orderStatus: + type: string + example: "'is-accepted', 'in-transit', 'cancelled', 'delivered'" + responses: + '200': + description: Order was successfully updated, return updated order + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Order not found + '409': + description: Order can not be updated because (it has already been completed(close), delivered, cancelled) + '500': + description: Internal server error diff --git a/src/docs/vendorProduct.yml b/src/docs/vendorProduct.yml new file mode 100644 index 0000000..3830bf4 --- /dev/null +++ b/src/docs/vendorProduct.yml @@ -0,0 +1,235 @@ +/product/collection: + get: + tags: + - Vendor product management + summary: Get all products + description: Return all product for logged user + security: + - bearerAuth: [] + responses: + '200': + description: Return all products for the user or return nothing if no product available + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/product/collection/{id}: + get: + tags: + - Vendor product management + summary: Get single product + description: return a product basing on id provided + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Return info for the product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product: + post: + tags: + - Vendor product management + summary: Creates new product + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + newPrice: + type: number + quantity: + type: number + images: + type: file + categories: + oneOf: + - type: string + - type: array + items: + type: string + example: "'category' or ['category1', 'category2', ...]" + expirationDate: + type: string + format: date + required: + - name + - description + - quantity + - newPrice + - categories + optional: + - expirationDate + responses: + '201': + description: Successfully added the product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/{id}: + put: + tags: + - Vendor product management + summary: Update a product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Successfully updated product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + + delete: + tags: + - Vendor product management + summary: Delete a product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Successfully deleted product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/images/{id}: + delete: + tags: + - Vendor product management + summary: Delete an image of product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + responses: + '200': + description: Successfully deleted product image + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/recommended: + get: + tags: + - Products Recommended + summary: Gets recommended products + security: + - bearerAuth: [] + parameters: + - in: query + name: categories + required: false + schema: + type: string + pattern: '^{"categories":\s*\[[^\]]*\]\s*}$' + description: JSON string representing an array of category IDs + - in: query + name: vendor + required: false + schema: + type: string + description: Vendor ID + responses: + '201': + description: Successfully data retrieved + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Products not found + '500': + description: Internal server error diff --git a/src/docs/wishListDocs.yml b/src/docs/wishListDocs.yml new file mode 100644 index 0000000..7f705f7 --- /dev/null +++ b/src/docs/wishListDocs.yml @@ -0,0 +1,97 @@ +/wish-list: + get: + tags: + - Wish list + summary: Get all products in wishlist + description: Return all products in wish list for authenticated buyer + security: + - bearerAuth: [] + responses: + '200': + description: Return all products in wish list for a buyer + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/wish-list/add/{id}: + post: + tags: + - Wish list + summary: Add product to wish list + description: Adds selected product (product id) to the wish list + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: Product id + responses: + '201': + description: Product Added to wish list + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found in wish list + '500': + description: Internal server error + +/wish-list/delete/{id}: + delete: + tags: + - Wish list + summary: Remove product from wish list + description: Remove product from wish list for an authenticated buyer + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: Product id + responses: + '200': + description: Product removed from wish list + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found in wish list + '500': + description: Internal server error + +/wish-list/clearAll: + delete: + tags: + - Wish list + summary: Clear entire wish list + description: Clears entire wish list for authenticated buyer + security: + - bearerAuth: [] + responses: + '200': + description: All products removed successfully + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error diff --git a/src/entities/Cart.ts b/src/entities/Cart.ts new file mode 100644 index 0000000..cf354a5 --- /dev/null +++ b/src/entities/Cart.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsBoolean } from 'class-validator'; +import { User } from './User'; +import { CartItem } from './CartItem'; + +@Entity() +export class Cart { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + user!: User; + + @OneToMany(() => CartItem, (cartItem: any) => cartItem.cart) + items!: CartItem[]; + + @Column('decimal') + totalAmount: number = 0; + + @Column({ default: false }) + @IsBoolean() + isCheckedOut: boolean = false; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + updateTotal (): void { + if (this.items) { + let total: number = 0; + for (let i = 0; i < this.items.length; i++) { + total += Number(this.items[i].total); + } + this.totalAmount = total; + } else { + this.totalAmount = 0; + } + } +} \ No newline at end of file diff --git a/src/entities/CartItem.ts b/src/entities/CartItem.ts new file mode 100644 index 0000000..da110d6 --- /dev/null +++ b/src/entities/CartItem.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Product } from './Product'; +import { Cart } from './Cart'; + +@Entity() +export class CartItem { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => Cart, cart => cart.items, { onDelete: 'CASCADE' }) + @IsNotEmpty() + cart!: Cart; + + @ManyToOne(() => Product) + @IsNotEmpty() + product!: Product; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + newPrice!: number; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; + + @Column('decimal') + total!: number; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @BeforeInsert() + @BeforeUpdate() + updateTotal (): void { + this.total = this.newPrice * this.quantity; + } +} \ No newline at end of file diff --git a/src/entities/Category.ts b/src/entities/Category.ts new file mode 100644 index 0000000..9b4f856 --- /dev/null +++ b/src/entities/Category.ts @@ -0,0 +1,20 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { IsNotEmpty, IsString } from 'class-validator'; + +@Entity() +export class Category { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column() + @IsNotEmpty() + @IsString() + name!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/Feedback.ts b/src/entities/Feedback.ts new file mode 100644 index 0000000..b64554e --- /dev/null +++ b/src/entities/Feedback.ts @@ -0,0 +1,30 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from './User'; +import { Product } from './Product'; +import { IsNotEmpty } from 'class-validator'; +import { Order } from './Order'; + +@Entity() +export class Feedback { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column('text') + comment!: string; + + @ManyToOne(() => User, user => user.feedbacks) + user!: User; + + @ManyToOne(() => Product, product => product.feedbacks) + product!: Product; + + @ManyToOne(() => Order, order => order.feedbacks) + order!: Order; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/Notification.ts b/src/entities/Notification.ts new file mode 100644 index 0000000..bc55b8b --- /dev/null +++ b/src/entities/Notification.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty } from 'class-validator'; +import { User } from './User'; +import { NotificationItem } from './NotificationItem'; + +@Entity() +export class Notification { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + user!: User; + + @OneToMany(() => NotificationItem, notificationItem => notificationItem.notification) + allNotifications!: NotificationItem[]; + + @Column('decimal') + unRead: number = 0; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + updateUnread (): void { + if (this.allNotifications) { + let unRead: number = 0; + for (let i = 0; i < this.allNotifications.length; i++) { + if(this.allNotifications[i].isRead === false){ + unRead +=1 + } + } + this.unRead = unRead; + } else { + this.unRead = 0; + } + } +} \ No newline at end of file diff --git a/src/entities/NotificationItem.ts b/src/entities/NotificationItem.ts new file mode 100644 index 0000000..df5881a --- /dev/null +++ b/src/entities/NotificationItem.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + } from 'typeorm'; +import { IsNotEmpty, IsIn, IsBoolean } from 'class-validator'; +import { Notification } from './Notification'; + +@Entity() +export class NotificationItem{ + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => Notification, nofication => nofication.allNotifications, { onDelete: 'CASCADE' }) + @IsNotEmpty() + notification!: Notification; + + @Column() + @IsNotEmpty() + content!: string + + @Column() + @IsNotEmpty() + @IsIn([ + 'product', + 'cart', + 'order', + 'user', + 'wish list', + 'coupon', + ]) + type!: string + + @Column({ default: false }) + @IsNotEmpty() + @IsBoolean() + isRead!: boolean; + + @Column({ nullable: true }) + link!: string; + + @CreateDateColumn() + createdAt!: Date; +} \ No newline at end of file diff --git a/src/entities/Order.ts b/src/entities/Order.ts new file mode 100644 index 0000000..7966b88 --- /dev/null +++ b/src/entities/Order.ts @@ -0,0 +1,74 @@ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsNumber, IsDate, IsIn } from 'class-validator'; +import { User } from './User'; +import { OrderItem } from './OrderItem'; +import { Transaction } from './transaction'; +import { Feedback } from './Feedback'; + +@Entity() +export class Order { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User, user => user.orders) + @IsNotEmpty() + buyer!: User; + + @OneToMany(() => OrderItem, orderItem => orderItem.order, { cascade: true }) + @IsNotEmpty() + orderItems!: OrderItem[]; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + totalPrice!: number; + + @OneToMany(() => Transaction, transaction => transaction.order) + transactions!: Transaction[]; + + @OneToMany(() => Feedback, feedback => feedback.order) + feedbacks!: Feedback[]; + + @Column({ default: 'order placed' }) + @IsNotEmpty() + @IsIn([ + 'order placed', + 'cancelled', + 'awaiting shipment', + 'in transit', + 'delivered', + 'received', + 'returned', + 'completed', + ]) + orderStatus!: string; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; + + @Column({ default: 'City, Country street address' }) + address!: string; + + @Column() + @IsDate() + @IsNotEmpty() + orderDate!: Date; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/OrderItem.ts b/src/entities/OrderItem.ts new file mode 100644 index 0000000..8de94dd --- /dev/null +++ b/src/entities/OrderItem.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Order } from './Order'; +import { Product } from './Product'; + +@Entity() +export class OrderItem { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => Order, order => order.orderItems) + @IsNotEmpty() + order!: Order; + + @ManyToOne(() => Product, product => product.orderItems) + @IsNotEmpty() + product!: Product; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + price!: number; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; +} \ No newline at end of file diff --git a/src/entities/Product.ts b/src/entities/Product.ts new file mode 100644 index 0000000..ce7f139 --- /dev/null +++ b/src/entities/Product.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + OneToMany, + JoinTable, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { IsNotEmpty, IsString, IsBoolean, ArrayNotEmpty, IsArray, MaxLength } from 'class-validator'; +import { User } from './User'; +import { Category } from './Category'; +import { Order } from './Order'; +import { Coupon } from './coupon'; +import { OrderItem } from './OrderItem'; +import { VendorOrderItem } from './VendorOrderItem'; +import { Feedback } from './Feedback'; + +@Entity() +@Unique(['id']) +export class Product { + static query() { + throw new Error('Method not implemented.'); + } + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + vendor!: User; + + @OneToMany(() => OrderItem, orderItem => orderItem.product) + orderItems!: OrderItem[]; + + @OneToMany(() => VendorOrderItem, vendorOrderItems => vendorOrderItems.product) + vendorOrderItems!: VendorOrderItem[]; + @OneToMany(() => Feedback, feedback => feedback.product) + feedbacks!: Feedback[]; + + @OneToOne(() => Coupon, (coupons: any) => coupons.product) + @JoinColumn() + coupons?: Coupon; + + @Column() + @IsNotEmpty() + @IsString() + name!: string; + + @Column() + @IsNotEmpty() + description!: string; + + @Column('simple-array') + @IsArray() + @ArrayNotEmpty() + @MaxLength(10) + images!: string[]; + + @Column('decimal') + @IsNotEmpty() + newPrice!: number; + + @Column('decimal', { nullable: true }) + oldPrice?: number; + + @Column('timestamp', { nullable: true }) + expirationDate?: Date; + + @Column('int') + @IsNotEmpty() + quantity!: number; + + @Column({ default: true }) + @IsBoolean() + isAvailable!: boolean; + + @ManyToMany(() => Category) + @JoinTable() + categories!: Category[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/User.ts b/src/entities/User.ts index 0c9a19a..78a1fd1 100644 --- a/src/entities/User.ts +++ b/src/entities/User.ts @@ -1,3 +1,4 @@ + import { Entity, PrimaryGeneratedColumn, @@ -6,12 +7,16 @@ import { CreateDateColumn, UpdateDateColumn, BeforeInsert, + OneToMany, } from 'typeorm'; import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator'; import { roles } from '../utils/roles'; +import { Order } from './Order'; +import { Transaction } from './transaction'; +import { Feedback } from './Feedback'; export interface UserInterface { - id: string; + id?: string; firstName: string; lastName: string; email: string; @@ -19,12 +24,12 @@ export interface UserInterface { gender: string; phoneNumber: string; photoUrl?: string; - verified: boolean; - status: 'active' | 'suspended'; + verified?: boolean; + status?: 'active' | 'suspended'; userType: 'Admin' | 'Buyer' | 'Vendor'; - role: string; - createdAt: Date; - updatedAt: Date; + role?: string; + createdAt?: Date; + updatedAt?: Date; } @Entity() @@ -93,14 +98,25 @@ export class User { @Column() role!: string; + @OneToMany(() => Order, (order: any) => order.buyer) + orders!: Order[]; + + @OneToMany(() => Transaction, transaction => transaction.user) + transactions!: Transaction[]; + @CreateDateColumn() createdAt!: Date; @UpdateDateColumn() updatedAt!: Date; + @Column({ type: 'numeric', precision: 24, scale: 2, default: 0 }) + accountBalance!: number; + @OneToMany(() => Feedback, feedback => feedback.product) + feedbacks!: Feedback[]; + @BeforeInsert() setRole (): void { this.role = this.userType === 'Vendor' ? roles.vendor : roles.buyer; } -} +} \ No newline at end of file diff --git a/src/entities/VendorOrderItem.ts b/src/entities/VendorOrderItem.ts new file mode 100644 index 0000000..f8613da --- /dev/null +++ b/src/entities/VendorOrderItem.ts @@ -0,0 +1,30 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Order } from './Order'; +import { Product } from './Product'; +import { VendorOrders } from './vendorOrders'; + +@Entity() +export class VendorOrderItem { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + 'id'!: string; + + @ManyToOne(() => VendorOrders, order => order.vendorOrderItems, {onDelete: 'CASCADE'}) + @IsNotEmpty() + 'order'!: VendorOrders; + + @ManyToOne(() => Product, product => product.vendorOrderItems, {onDelete: 'CASCADE'}) + @IsNotEmpty() + 'product'!: Product; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + 'price/unit'!: number; + + @Column('int') + @IsNotEmpty() + @IsNumber() + 'quantity'!: number; +} \ No newline at end of file diff --git a/src/entities/coupon.ts b/src/entities/coupon.ts new file mode 100644 index 0000000..b4c9431 --- /dev/null +++ b/src/entities/coupon.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { IsDate, IsNotEmpty, IsArray, IsIn } from 'class-validator'; +import { User } from './User'; +import { Product } from './Product'; + +@Entity() +@Unique(['id']) +@Unique(['code']) // Ensure only 'code' is unique +export class Coupon { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + @JoinColumn() + vendor!: User; + + @ManyToOne(() => Product, product => product.coupons) + @IsNotEmpty() + @JoinColumn() + product!: Product; + + @Column() + @IsNotEmpty() + code!: string; + + @Column() + @IsNotEmpty() + @IsIn(['percentage', 'money']) + discountType!: 'percentage' | 'money'; + + @Column('float') + @IsNotEmpty() + discountRate!: number; + + @Column('timestamp', { nullable: false }) + @IsNotEmpty() + @IsDate() + expirationDate?: Date; + + @Column('int', { default: 0 }) + @IsNotEmpty() + usageTimes!: number; + + @Column('int') + @IsNotEmpty() + maxUsageLimit!: number; + + @Column('simple-array', { nullable: true, default: '' }) + @IsArray() + usedBy!: string[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/transaction.ts b/src/entities/transaction.ts new file mode 100644 index 0000000..d475812 --- /dev/null +++ b/src/entities/transaction.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsString, IsNumber } from 'class-validator'; +import { User } from './User'; +import { Order } from './Order'; +import { Product } from './Product'; // Assuming Product entity exists + +@Entity() +export class Transaction { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @ManyToOne(() => Order, { nullable: true }) + @JoinColumn({ name: 'orderId' }) + order?: Order; + + @ManyToOne(() => Product, { nullable: true }) + @JoinColumn({ name: 'productId' }) + product?: Product; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + amount!: number; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + previousBalance!: number; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + currentBalance!: number; + + @Column({ type: 'enum', enum: ['debit', 'credit'] }) + @IsNotEmpty() + @IsString() + type!: 'debit' | 'credit'; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/vendorOrders.ts b/src/entities/vendorOrders.ts new file mode 100644 index 0000000..d2a784c --- /dev/null +++ b/src/entities/vendorOrders.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsNumber, IsDate, IsIn, isNotEmpty } from 'class-validator'; +import { User } from './User'; +import { OrderItem } from './OrderItem'; +import { Transaction } from './transaction'; +import { Order } from './Order'; +import { VendorOrderItem } from './VendorOrderItem'; + +@Entity() +export class VendorOrders { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + vendor!: User; + + @OneToMany(() => VendorOrderItem, vendorOrderItems => vendorOrderItems.order, { cascade: true }) + @IsNotEmpty() + vendorOrderItems!: VendorOrderItem[]; + + @ManyToOne(() => Order) + @IsNotEmpty() + order!: Order; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + totalPrice!: number; + + @Column({ default: 'pending' }) + @IsIn(['pending', 'is-accepted', 'in-transit', 'cancelled', 'delivered', 'completed']) + orderStatus!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/wishList.ts b/src/entities/wishList.ts new file mode 100644 index 0000000..a1f6a55 --- /dev/null +++ b/src/entities/wishList.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + BaseEntity, + Column, + Unique, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { User } from './User'; + +@Entity('wishlist') +@Unique(['id']) +export class wishList extends BaseEntity { + @PrimaryGeneratedColumn() + @IsNotEmpty() + id!: number; + + @Column() + @IsNotEmpty() + @IsString() + productId!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + buyer!: User; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/helper/cartItemValidator.ts b/src/helper/cartItemValidator.ts new file mode 100644 index 0000000..c8de0e8 --- /dev/null +++ b/src/helper/cartItemValidator.ts @@ -0,0 +1,19 @@ +import Joi from 'joi'; + +interface CartItem { + productId: string; + quantity: number; +} + +export const validateCartItem = (product: CartItem): Joi.ValidationResult => { + const schema = Joi.object({ + productId: Joi.string().min(3).required().messages({ + 'any.required': 'id is required.', + }), + quantity: Joi.number().required().messages({ + 'any.required': 'quantity is required.', + }), + }); + + return schema.validate(product); +}; diff --git a/src/helper/couponValidator.ts b/src/helper/couponValidator.ts new file mode 100644 index 0000000..9736aa8 --- /dev/null +++ b/src/helper/couponValidator.ts @@ -0,0 +1,58 @@ +import Joi from 'joi'; +import { Coupon } from '../entities/coupon'; + +export const validateCoupon = ( + coupon: Pick +): Joi.ValidationResult => { + const schema = Joi.object({ + code: Joi.string().min(5).required().messages({ + 'any.required': 'code is required.', + 'string.min': 'code must be at least 5 characters long.', + }), + discountRate: Joi.number().required().messages({ + 'any.required': 'discountRate is required.', + }), + expirationDate: Joi.date().required().messages({ + 'any.required': 'expirationDate is required.', + }), + maxUsageLimit: Joi.number().required().messages({ + 'any.required': 'maxUsageLimit is required.', + }), + discountType: Joi.string().required().messages({ + 'any.required': 'discountType is required.', + }), + product: Joi.string().required().messages({ + 'any.required': 'product is required.', + }), + }); + + return schema.validate(coupon); +}; + +export const validateCouponUpdate = ( + coupon: Partial> +): Joi.ValidationResult => { + const schema = Joi.object({ + code: Joi.string().min(5).messages({ + 'string.min': 'code must be at least 5 characters long.', + }), + discountRate: Joi.number().messages({ + 'number.base': 'discountRate must be a number.', + }), + expirationDate: Joi.date().messages({ + 'date.base': 'expirationDate must be a valid date.', + }), + maxUsageLimit: Joi.number().messages({ + 'number.base': 'maxUsageLimit must be a number.', + }), + discountType: Joi.string().messages({ + 'string.base': 'discountType must be a string.', + }), + }) + .min(1) + .messages({ + 'object.min': 'At least one field must be updated.', + }); + + return schema.validate(coupon); +}; diff --git a/src/helper/productValidator.ts b/src/helper/productValidator.ts new file mode 100644 index 0000000..4d93847 --- /dev/null +++ b/src/helper/productValidator.ts @@ -0,0 +1,30 @@ +import Joi from 'joi'; +import { Product } from '../lib/types'; + +export const validateProduct = ( + product: Pick +): Joi.ValidationResult => { + const schema = Joi.object({ + name: Joi.string().min(3).required().messages({ + 'any.required': 'name is required.', + }), + description: Joi.string().min(3).required().messages({ + 'any.required': 'description is required.', + }), + newPrice: Joi.number().required().messages({ + 'any.required': 'newPrice is required.', + }), + quantity: Joi.number().required().messages({ + 'any.required': 'quantity is required.', + }), + categories: Joi.alternatives() + .try(Joi.array().items(Joi.string()).min(1).required(), Joi.string().required()) + .messages({ + 'any.required': 'at least one category is required.', + }), + expirationDate: Joi.date(), + oldPrice: Joi.number(), + }); + + return schema.validate(product); +}; diff --git a/src/index.ts b/src/index.ts index fb60cd4..d689c27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,14 +5,27 @@ import router from './routes'; import { addDocumentation } from './startups/docs'; import 'reflect-metadata'; import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import passport from 'passport'; import { CustomError, errorHandler } from './middlewares/errorHandler'; import morgan from 'morgan'; import { dbConnection } from './startups/dbConnection'; + +import { Server } from 'socket.io'; +import { init as initSocketIO } from './utils/socket'; + dotenv.config(); export const app = express(); const port = process.env.PORT || 8000; +app.use( + session({ + secret: 'keyboard cat', + }) +); +app.use(passport.initialize()); +app.use(passport.session()); app.use(express.json()); app.use(cookieParser()); app.use(cors({ origin: '*' })); @@ -36,3 +49,14 @@ app.use(morgan(morganFormat)); export const server = app.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); }); + +// Socket.IO setup +const io = initSocketIO(server); + +io.on('connection', socket => { + console.log('Client connected'); + + socket.on('disconnect', () => { + console.log('Client disconnected'); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..b8598e8 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,16 @@ +import { User } from '../entities/User'; + +export type Product = { + id: string; + vendor: User; + name: string; + description: string; + images: string[]; + newPrice: number; + oldPrice?: number; + expirationDate?: Date; + quantity: number; + isAvailable: boolean; + createdAt: Date; + updatedAt: Date; +}; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 5e25feb..d028c83 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -19,7 +19,6 @@ const errorHandler = (err: CustomError, req: Request, res: Response, next: NextF status: err.statusCode, message: err.message, }); - console.error(err.stack); }; export { CustomError, errorHandler }; diff --git a/src/middlewares/isAllowed.ts b/src/middlewares/isAllowed.ts index 2894be6..77c115b 100644 --- a/src/middlewares/isAllowed.ts +++ b/src/middlewares/isAllowed.ts @@ -1,7 +1,7 @@ -import { NextFunction, Request, Response } from "express"; -import { User } from "../entities/User"; -import { getRepository } from "typeorm"; -import { responseError } from "../utils/response.utils"; +import { NextFunction, Request, Response } from 'express'; +import { User } from '../entities/User'; +import { getRepository } from 'typeorm'; +import { responseError } from '../utils/response.utils'; export interface UserInterface { id: string; @@ -22,33 +22,33 @@ export interface UserInterface { declare module 'express' { interface Request { - user?: Partial; + user?: Partial; } } export const checkUserStatus = async (req: Request, res: Response, next: NextFunction) => { - try { - if (!req.user) { - return responseError(res, 401, 'Authentication required'); - } + try { + if (!req.user) { + return responseError(res, 401, 'Authentication required'); + } - const userId = req.user.id; + const userId = req.user.id; - const userRepository = getRepository(User); + const userRepository = getRepository(User); - const user = await userRepository.findOne({ where: { id: userId } }); - if (!user) { - return responseError(res, 401, 'User not found'); - } + const user = await userRepository.findOne({ where: { id: userId } }); + if (!user) { + return responseError(res, 401, 'User not found'); + } - if (user.status === 'active') { - next(); - } else if (user.status === 'suspended') { - return responseError(res, 403, 'You have been suspended. Please contact our support team.'); - } else { - return responseError(res, 403, 'Unauthorized action'); - } - } catch (error) { - responseError(res, 400, (error as Error).message); + if (user.status === 'active') { + next(); + } else if (user.status === 'suspended') { + return responseError(res, 403, 'You have been suspended. Please contact our support team.'); + } else { + return responseError(res, 403, 'Unauthorized action'); } + } catch (error) { + responseError(res, 400, (error as Error).message); + } }; diff --git a/src/middlewares/multer.ts b/src/middlewares/multer.ts new file mode 100644 index 0000000..0c1eba5 --- /dev/null +++ b/src/middlewares/multer.ts @@ -0,0 +1,14 @@ +import multer from 'multer'; +import path from 'path'; + +export default multer({ + storage: multer.diskStorage({}), + fileFilter: (req, file, next) => { + const ext = path.extname(file.originalname); + const supported = ['.png', '.jpg', '.jpeg', '.webp']; + if (!supported.includes(ext)) { + next(new Error(`file type not supported\ntry ${supported} are supported`)); + } + next(null, true); + }, +}); diff --git a/src/middlewares/optionalAuthorization.ts b/src/middlewares/optionalAuthorization.ts new file mode 100644 index 0000000..0024ee1 --- /dev/null +++ b/src/middlewares/optionalAuthorization.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import jwt, { type JwtPayload, type Secret } from 'jsonwebtoken'; +import { responseError } from '../utils/response.utils'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface AuthRequest extends Request { + user?: UserInterface; +} + +export const optinalAuthMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (authHeader !== undefined) { + const [bearer, token] = authHeader.split(' '); + + if (bearer !== 'Bearer' || token === undefined) { + responseError(res, 401, 'Please login'); + } + + if (token !== undefined) { + try { + jwt.verify(token, process.env.JWT_SECRET as Secret, async (err, decodedToken) => { + if (err !== null) { + responseError(res, 403, 'Access denied'); + } + + if (decodedToken !== undefined) { + const { email } = decodedToken as JwtPayload; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + responseError(res, 401, 'You are not Authorized'); + } + + req.user = user as UserInterface; + next(); + } + }); + } catch (error) { + responseError(res, 401, 'Invalid token'); + } + } + } else { + next(); + } +}; diff --git a/src/middlewares/verifyToken.ts b/src/middlewares/verifyToken.ts new file mode 100644 index 0000000..3fe4f1a --- /dev/null +++ b/src/middlewares/verifyToken.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import jwt, { type JwtPayload, type Secret } from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface AuthRequest extends Request { + user?: UserInterface; +} + +export const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (authHeader === undefined) { + return res.status(401).json({ error: 'Access denied. No token provided.' }); + } + + const [bearer, token] = authHeader.split(' '); + + if (bearer !== 'Bearer' || token === undefined) { + return res.status(401).json({ error: 'Please login' }); + } + + if (token !== undefined) { + try { + jwt.verify(token, process.env.JWT_SECRET as Secret, async (err, decodedToken) => { + if (err !== null) { + return res.status(403).json({ status: 'error', error: 'Access denied' }); + } + + if (decodedToken !== undefined) { + const { email } = decodedToken as JwtPayload; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(401).json({ status: 'error', error: 'You are not Authorized' }); + } + + req.user = user as UserInterface; + next(); + } + }); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } + } +}; diff --git a/src/node-nlp.d.ts b/src/node-nlp.d.ts new file mode 100644 index 0000000..8228fa6 --- /dev/null +++ b/src/node-nlp.d.ts @@ -0,0 +1 @@ +declare module 'node-nlp'; diff --git a/src/routes/CartRoutes.ts b/src/routes/CartRoutes.ts new file mode 100644 index 0000000..ca74292 --- /dev/null +++ b/src/routes/CartRoutes.ts @@ -0,0 +1,12 @@ +import { RequestHandler, Router } from 'express'; +import { createCart, readCart, removeProductInCart, clearCart } from '../controllers/cartController'; +import { optinalAuthMiddleware } from '../middlewares/optionalAuthorization'; + +const router = Router(); + +router.post('/', optinalAuthMiddleware as RequestHandler, createCart); +router.get('/', optinalAuthMiddleware as RequestHandler, readCart); +router.delete('/:id', optinalAuthMiddleware as RequestHandler, removeProductInCart); +router.delete('/', optinalAuthMiddleware as RequestHandler, clearCart); + +export default router; diff --git a/src/routes/NoficationRoutes.ts b/src/routes/NoficationRoutes.ts new file mode 100644 index 0000000..1e8d9a5 --- /dev/null +++ b/src/routes/NoficationRoutes.ts @@ -0,0 +1,17 @@ +import { RequestHandler, Router } from 'express'; +import { authMiddleware } from '../middlewares/verifyToken'; +import { getAllNotifications, updateNotifications, deleteSelectedNotifications, deleteAllNotifications, updateAllNotifications } from '../controllers/notificationControllers'; + +const router = Router(); + +router.get('/', authMiddleware as RequestHandler, getAllNotifications); + +router.put('/', authMiddleware as RequestHandler, updateNotifications); + +router.put('/all', authMiddleware as RequestHandler, updateAllNotifications); + +router.delete('/', authMiddleware as RequestHandler, deleteSelectedNotifications); + +router.delete('/all', authMiddleware as RequestHandler, deleteAllNotifications); + +export default router; \ No newline at end of file diff --git a/src/routes/ProductRoutes.ts b/src/routes/ProductRoutes.ts new file mode 100644 index 0000000..3ab9f95 --- /dev/null +++ b/src/routes/ProductRoutes.ts @@ -0,0 +1,58 @@ +import { RequestHandler, Router } from 'express'; + +import { productStatus, searchProduct, } from '../controllers/index'; +import { hasRole } from '../middlewares/roleCheck'; +import upload from '../middlewares/multer'; +import { authMiddleware } from '../middlewares/verifyToken'; + +import { + createProduct, + updateProduct, + removeProductImage, + readProducts, + readProduct, + deleteProduct, + getRecommendedProducts, + listAllProducts, + singleProduct, + createOrder, + getOrders, + updateOrder, + getOrdersHistory,Payment, + getSingleVendorOrder, + getVendorOrders, + updateVendorOrder, + getBuyerVendorOrders, + getSingleBuyerVendorOrder, + updateBuyerVendorOrder, +} from '../controllers'; +const router = Router(); +router.get('/all', listAllProducts); +router.get('/recommended', authMiddleware as RequestHandler, hasRole('BUYER'), getRecommendedProducts); +router.get('/collection', authMiddleware as RequestHandler, hasRole('VENDOR'), readProducts); +router.get('/', authMiddleware as RequestHandler, hasRole('BUYER'), readProducts); +router.get('/:id', singleProduct); +router.get('/collection/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), readProduct); +router.post('/', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), createProduct); +router.put('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), updateProduct); +router.delete('/images/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), removeProductImage); +router.delete('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), deleteProduct); +router.put('/availability/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), productStatus); + +router.post('/orders', authMiddleware as RequestHandler, hasRole('BUYER'), createOrder); +router.get('/client/orders', authMiddleware as RequestHandler, hasRole('BUYER'), getOrders); +router.put('/client/orders/:orderId', authMiddleware as RequestHandler, hasRole('BUYER'), updateOrder); +router.get('/orders/history', authMiddleware as RequestHandler, hasRole('BUYER'), getOrdersHistory); + +// Vendor order management +router.get('/vendor/orders', authMiddleware as RequestHandler, hasRole('VENDOR'), getVendorOrders); +router.get('/vendor/orders/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), getSingleVendorOrder); +router.put('/vendor/orders/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), updateVendorOrder); + +// Admin order management +router.get('/admin/orders', authMiddleware as RequestHandler, hasRole('ADMIN'), getBuyerVendorOrders); +router.get('/admin/orders/:id', authMiddleware as RequestHandler, hasRole('ADMIN'), getSingleBuyerVendorOrder); +router.put('/admin/orders/:id', authMiddleware as RequestHandler, hasRole('ADMIN'), updateBuyerVendorOrder); +router.post('/payment/:id', authMiddleware as RequestHandler, hasRole('BUYER'), Payment) + +export default router; \ No newline at end of file diff --git a/src/routes/UserRoutes.ts b/src/routes/UserRoutes.ts index 9eac282..79b0551 100644 --- a/src/routes/UserRoutes.ts +++ b/src/routes/UserRoutes.ts @@ -1,4 +1,7 @@ import { Router } from 'express'; +import { responseError } from '../utils/response.utils'; +import { UserInterface } from '../entities/User'; +import jwt from 'jsonwebtoken'; import { disable2FA, enable2FA, @@ -12,10 +15,11 @@ import { logout, } from '../controllers'; -import { activateUser, disactivateUser } from '../controllers/index'; +import { activateUser, disactivateUser, userProfileUpdate } from '../controllers/index'; import { hasRole } from '../middlewares/roleCheck'; import { isTokenValide } from '../middlewares/isValid'; - +import passport from 'passport'; +import '../utils/auth'; const router = Router(); router.post('/register', userRegistration); @@ -30,5 +34,40 @@ router.post('/activate', isTokenValide, hasRole('ADMIN'), activateUser); router.post('/deactivate', isTokenValide, hasRole('ADMIN'), disactivateUser); router.post('/password/reset', userPasswordReset); router.post('/password/reset/link', sendPasswordResetLink); +router.put('/update', userProfileUpdate); + +router.get('/google-auth', passport.authenticate('google', { scope: ['profile', 'email'] })); +router.get( + '/auth/google/callback', + passport.authenticate('google', { + successRedirect: '/user/login/success', + failureRedirect: '/user/login/failed', + }) +); +router.get('/login/success', async (req, res) => { + const user = req.user as UserInterface; + if (!user) { + responseError(res, 404, 'user not found'); + } + const payload = { + id: user?.id, + email: user?.email, + role: user?.role, + }; + const token = jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: '24h' }); + res.status(200).json({ + status: 'success', + data: { + token: token, + message: 'Login success', + }, + }); +}); +router.get('/login/failed', async (req, res) => { + res.status(401).json({ + status: false, + message: 'Login failed', + }); +}); export default router; diff --git a/src/routes/chatBot.ts b/src/routes/chatBot.ts new file mode 100644 index 0000000..f54638a --- /dev/null +++ b/src/routes/chatBot.ts @@ -0,0 +1,10 @@ +import { RequestHandler, Router } from 'express'; +import { chatBotController } from '../controllers/chatBotController'; +import { optinalAuthMiddleware } from '../middlewares/optionalAuthorization'; + +const router = Router(); + +router.post('/', optinalAuthMiddleware as RequestHandler, chatBotController); + + +export default router; diff --git a/src/routes/couponRoutes.ts b/src/routes/couponRoutes.ts new file mode 100644 index 0000000..3378fbe --- /dev/null +++ b/src/routes/couponRoutes.ts @@ -0,0 +1,22 @@ +import { RequestHandler, Router } from 'express'; +import { + createCoupon, + updateCoupon, + accessAllCoupon, + readCoupon, + deleteCoupon, + buyerApplyCoupon, +} from '../controllers/couponController'; +import { hasRole } from '../middlewares/roleCheck'; +import { authMiddleware } from '../middlewares/verifyToken'; + +const router = Router(); + +router.post('/vendor/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), createCoupon); +router.put('/vendor/:id/update-coupon/:code', authMiddleware as RequestHandler, hasRole('VENDOR'), updateCoupon); +router.get('/vendor/:id/checkout/:code', authMiddleware as RequestHandler, hasRole('VENDOR'), readCoupon); +router.get('/vendor/:id/access-coupons', authMiddleware as RequestHandler, hasRole('VENDOR'), accessAllCoupon); +router.delete('/vendor/:id/checkout/delete', authMiddleware as RequestHandler, hasRole('VENDOR'), deleteCoupon); +router.post('/apply', authMiddleware as RequestHandler, hasRole('BUYER'), buyerApplyCoupon); + +export default router; diff --git a/src/routes/feedbackRoutes.ts b/src/routes/feedbackRoutes.ts new file mode 100644 index 0000000..3ada81b --- /dev/null +++ b/src/routes/feedbackRoutes.ts @@ -0,0 +1,19 @@ +import { RequestHandler, Router } from 'express'; +import { + createFeedback, + updateFeedback, + deleteFeedback, + adminDeleteFeedback +} from '../controllers/feedbackController' +import { authMiddleware } from '../middlewares/verifyToken'; +import { hasRole } from '../middlewares/roleCheck'; + + +const router = Router(); + +router.post('/:productId/new', authMiddleware as RequestHandler, hasRole('BUYER'), createFeedback); +router.put('/update/:feedbackId', authMiddleware as RequestHandler, hasRole('BUYER'), updateFeedback ); +router.delete('/delete/:feedbackId', authMiddleware as RequestHandler, hasRole('BUYER'), deleteFeedback); +router.delete('/admin/delete/:feedbackId', authMiddleware as RequestHandler, hasRole('ADMIN'), adminDeleteFeedback ); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 851f5c2..75b31ad 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,13 +1,44 @@ -import { Request, Response, Router } from 'express'; -import { responseSuccess } from '../utils/response.utils'; +import { Request, RequestHandler, Response, Router } from 'express'; +import { responseServerError, responseSuccess } from '../utils/response.utils'; import userRoutes from './UserRoutes'; +import productRoutes from './ProductRoutes'; +import wishListRoutes from './wishListRoute'; +import couponRoute from './couponRoutes'; +import cartRoutes from './CartRoutes'; +import feedbackRoute from './feedbackRoutes'; +import notificationRoute from './NoficationRoutes' +import { authMiddleware } from '../middlewares/verifyToken'; +import chatBot from './chatBot'; const router = Router(); -router.get('/api/v1/status', (req: Request, res: Response) => { +router.get('/', (req: Request, res: Response) => { return responseSuccess(res, 200, 'This is a testing route.'); }); router.use('/user', userRoutes); +router.use('/product', productRoutes); +router.use('/wish-list', wishListRoutes); +router.use('/cart', cartRoutes); +router.use('/coupons', couponRoute); +router.use('/feedback', feedbackRoute); +router.use('/notification', notificationRoute); +router.use('/chat', chatBot); -export default router; +// ROUTES FOR TESTING PURPOSE +router.get('/test', (req: Request, res: Response) => { + res.status(200).json({ message: 'Route works!' }); +}); +router.post('/test/posting', (req: Request, res: Response) =>{ + return responseSuccess(res, 200, req.body); +}); + +router.get('/test/secure', authMiddleware as RequestHandler, (req: Request, res: Response) =>{ + responseSuccess(res, 200, 'This is a secured route.'); +}); + +router.get('/test/error', (req: Request, res: Response) => { + responseServerError(res, 'This is server error route.'); +}); + +export default router; \ No newline at end of file diff --git a/src/routes/wishListRoute.ts b/src/routes/wishListRoute.ts new file mode 100644 index 0000000..ea96e40 --- /dev/null +++ b/src/routes/wishListRoute.ts @@ -0,0 +1,31 @@ +import { RequestHandler, Router } from 'express'; +import { authMiddleware } from '../middlewares/verifyToken'; +import { hasRole } from '../middlewares'; +import { checkUserStatus } from '../middlewares/isAllowed'; +import { + wishlistAddProduct, + wishlistRemoveProduct, + wishlistGetProducts, + wishlistClearAllProducts, +} from '../controllers/wishListController'; + +const router = Router(); + +router.post('/add/:id', authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'), wishlistAddProduct); +router.get('/', authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'), wishlistGetProducts); +router.delete( + '/delete/:id', + authMiddleware as RequestHandler, + checkUserStatus, + hasRole('BUYER'), + wishlistRemoveProduct +); +router.delete( + '/clearAll', + authMiddleware as RequestHandler, + checkUserStatus, + hasRole('BUYER'), + wishlistClearAllProducts +); + +export default router; diff --git a/src/services/adminOrderServices/readOrder.ts b/src/services/adminOrderServices/readOrder.ts new file mode 100644 index 0000000..4bb20f0 --- /dev/null +++ b/src/services/adminOrderServices/readOrder.ts @@ -0,0 +1,158 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { VendorOrderItem } from '../../entities/VendorOrderItem'; +import { VendorOrders } from '../../entities/vendorOrders'; +import { Order } from '../../entities/Order'; + +export const getBuyerVendorOrdersService = async (req: Request, res: Response) => { + try { + const vendorOrderRepository = getRepository(VendorOrders); + const orderRepository = getRepository(Order); + + const orders = await orderRepository.find({ + relations: ['buyer', 'orderItems'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + if (!orders.length) { + return responseError(res, 200, `There is no pending orders from buyer`, { orders: [] }); + } + + const sanitizedOrdersResponse = []; + + for (const order of orders) { + const vendorOrders = await vendorOrderRepository.find({ + where: { + order: { + id: order.id, + }, + }, + relations: ['vendor', 'vendorOrderItems', 'vendorOrderItems.product'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + sanitizedOrdersResponse.push({ + id: order.id, + totalPrice: order.totalPrice, + totalProducts: order.orderItems.length, + orderStatus: order.orderStatus, + address: order.address, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + email: order.buyer.lastName, + gender: order.buyer.gender, + phoneNumber: order.buyer.phoneNumber, + photoUrl: order.buyer.photoUrl, + }, + vendors: vendorOrders.map(vendoOrder => ({ + id: vendoOrder.vendor.id, + firstName: vendoOrder.vendor.firstName, + lastName: vendoOrder.vendor.lastName, + email: vendoOrder.vendor.lastName, + gender: vendoOrder.vendor.gender, + phoneNumber: vendoOrder.vendor.phoneNumber, + photoUrl: vendoOrder.vendor.photoUrl, + order: { + id: vendoOrder.id, + totalPrice: vendoOrder.totalPrice, + orderStatus: vendoOrder.orderStatus, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + orderItems: vendoOrder.vendorOrderItems, + }, + })), + }); + } + + responseSuccess(res, 200, 'Orders retrieved successfully', { + totalOrders: orders.length, + orders: sanitizedOrdersResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; + +// Get single vendor order info +export const getSingleBuyerVendorOrderService = async (req: Request, res: Response) => { + try { + const orderId = req.params.id; + + const vendorOrderRepository = getRepository(VendorOrders); + const orderRepository = getRepository(Order); + + const order = await orderRepository.findOne({ + where: { + id: orderId, + }, + relations: ['buyer', 'orderItems'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + if (!order) { + return responseError(res, 404, `Order Not Found.`); + } + + const vendorOrders = await vendorOrderRepository.find({ + where: { + order: { + id: order.id, + }, + }, + relations: ['vendor', 'vendorOrderItems', 'vendorOrderItems.product'], + }); + + const sanitizedOrderResponse = { + id: order.id, + totalPrice: order.totalPrice, + totalProducts: order.orderItems.length, + orderStatus: order.orderStatus, + address: order.address, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + email: order.buyer.lastName, + gender: order.buyer.gender, + phoneNumber: order.buyer.phoneNumber, + photoUrl: order.buyer.photoUrl, + }, + vendors: vendorOrders.map(vendoOrder => ({ + id: vendoOrder.vendor.id, + firstName: vendoOrder.vendor.firstName, + lastName: vendoOrder.vendor.lastName, + email: vendoOrder.vendor.lastName, + gender: vendoOrder.vendor.gender, + phoneNumber: vendoOrder.vendor.phoneNumber, + photoUrl: vendoOrder.vendor.photoUrl, + order: { + id: vendoOrder.id, + totalPrice: vendoOrder.totalPrice, + orderStatus: vendoOrder.orderStatus, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + orderItems: vendoOrder.vendorOrderItems, + }, + })), + }; + + responseSuccess(res, 200, 'Order retrieved successfully', { + order: sanitizedOrderResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/adminOrderServices/updateOrder.ts b/src/services/adminOrderServices/updateOrder.ts new file mode 100644 index 0000000..876160f --- /dev/null +++ b/src/services/adminOrderServices/updateOrder.ts @@ -0,0 +1,107 @@ +import { Request, Response } from 'express'; +import { Not, getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { VendorOrderItem } from '../../entities/VendorOrderItem'; +import { VendorOrders } from '../../entities/vendorOrders'; +import { Order } from '../../entities/Order'; +import { getIO } from '../../utils/socket'; + +export const updateBuyerVendorOrderService = async (req: Request, res: Response) => { + try { + const orderId = req.params.id; + + const vendorOrderRepository = getRepository(VendorOrders); + const orderRepository = getRepository(Order); + + const order = await orderRepository.findOne({ + where: { + id: orderId, + }, + relations: ['buyer', 'orderItems'], + }); + + if (!order) { + return responseError(res, 404, `Order Not Found.`); + } + + if (order.orderStatus === 'completed') { + return responseError(res, 409, 'The order has already been completed.'); + } + + if (order.orderStatus !== 'received') { + return responseError(res, 409, 'Order closure failed: The buyer has not received the item yet.'); + } + + const vendorOrders = await vendorOrderRepository.find({ + where: { + order: { + id: order.id, + }, + }, + relations: ['vendor', 'vendorOrderItems', 'vendorOrderItems.product'], + }); + + for (const order of vendorOrders) { + if (order.orderStatus !== 'delivered') { + return responseError(res, 409, 'Order closure failed: Some vendors have not yet delivered items to the buyer.'); + } + } + + // Update Whole Order + + order.orderStatus = 'completed'; + await orderRepository.save(order); + + const updatedVendorOrder = vendorOrders.map(async order => { + order.orderStatus = 'completed'; + await vendorOrderRepository.save(order); + }); + + const sanitizedOrderResponse = { + id: order.id, + totalPrice: order.totalPrice, + totalProducts: order.orderItems.length, + orderStatus: order.orderStatus, + address: order.address, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + email: order.buyer.lastName, + gender: order.buyer.gender, + phoneNumber: order.buyer.phoneNumber, + photoUrl: order.buyer.photoUrl, + }, + vendors: vendorOrders.map(vendoOrder => ({ + id: vendoOrder.vendor.id, + firstName: vendoOrder.vendor.firstName, + lastName: vendoOrder.vendor.lastName, + email: vendoOrder.vendor.lastName, + gender: vendoOrder.vendor.gender, + phoneNumber: vendoOrder.vendor.phoneNumber, + photoUrl: vendoOrder.vendor.photoUrl, + order: { + id: vendoOrder.id, + totalPrice: vendoOrder.totalPrice, + orderStatus: vendoOrder.orderStatus, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + orderItems: vendoOrder.vendorOrderItems, + }, + })), + }; + + getIO().emit('orders', { + action: 'admin update', + order: sanitizedOrderResponse, + }); + + responseSuccess(res, 200, 'Order updated successfully', { + order: sanitizedOrderResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/cartServices/clearCart.ts b/src/services/cartServices/clearCart.ts new file mode 100644 index 0000000..4806e01 --- /dev/null +++ b/src/services/cartServices/clearCart.ts @@ -0,0 +1,60 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; + +export const clearCartService = async (req: Request, res: Response) => { + try { + const cartRepository = getRepository(Cart); + + if (req.user) { + const cart = await cartRepository.findOne({ + where: { + user: { + id: req.user.id, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'Cart cleared successfully', { cart: [] }); + return; + } + + if (!req.user) { + if (!req.cookies.cartId) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + const cart = await cartRepository.findOne({ + where: { + id: req.cookies.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'Cart cleared successfully', { cart: [] }); + return; + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/cartServices/createCart.ts b/src/services/cartServices/createCart.ts new file mode 100644 index 0000000..36232a3 --- /dev/null +++ b/src/services/cartServices/createCart.ts @@ -0,0 +1,151 @@ +import { Request, Response } from 'express'; +import { CartItem } from '../../entities/CartItem'; +import { Cart } from '../../entities/Cart'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { validateCartItem } from '../../helper/cartItemValidator'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const createCartService = async (req: Request, res: Response) => { + try { + const { error } = validateCartItem(req.body); + if (error) { + return responseError(res, 400, error.details[0].message); + } + + if (req.body.quantity < 1) { + responseError(res, 400, 'Quantity must be greater than 0'); + return; + } + + const product = await getRepository(Product).findOne({ + where: { id: req.body.productId }, + }); + + if (!product) { + responseError(res, 404, 'Product not found, try again.'); + return; + } + + if (req.user) { + const cartRepository = getRepository(Cart); + const cartItemRepository = getRepository(CartItem); + + let cart = await cartRepository.findOne({ + where: { + user: { id: req.user.id }, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + cart = new Cart(); + cart.user = req.user as User; + await cartRepository.save(cart); + } + + let cartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: req.body.productId }, + }, + }); + + if (cartItem) { + cartItem.quantity = req.body.quantity; + cartItem.newPrice = product.newPrice; + await cartItemRepository.save(cartItem); + } else { + cartItem = new CartItem(); + cartItem.cart = cart; + cartItem.product = product; + cartItem.newPrice = product.newPrice; + cartItem.quantity = req.body.quantity; + await cartItemRepository.save(cartItem); + } + + // Fetch the updated cart with items and user + cart = await cartRepository.findOne({ + where: { id: cart.id }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + // Update the total amount in the cart + cart.updateTotal(); + await cartRepository.save(cart); + + const responseCart = { + ...cart, + user: cart?.user.id, + }; + + responseSuccess(res, 201, 'cart updated successfully', { cart: responseCart }); + return; + } + } + + if (!req.user) { + // guest user + const cartRepository = getRepository(Cart); + const cartItemRepository = getRepository(CartItem); + + let cart; + if (req.cookies.cartId) { + cart = await cartRepository.findOne({ + where: { + id: req.cookies?.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + } + + if (!cart) { + cart = new Cart(); + await cartRepository.save(cart); + } + + let cartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: req.body.productId }, + }, + }); + + if (cartItem) { + cartItem.quantity = req.body.quantity; + cartItem.newPrice = product.newPrice; + await cartItemRepository.save(cartItem); + } else { + cartItem = new CartItem(); + cartItem.cart = cart; + cartItem.product = product; + cartItem.newPrice = product.newPrice; + cartItem.quantity = req.body.quantity; + await cartItemRepository.save(cartItem); + } + + // Fetch the updated cart with items and user + cart = await cartRepository.findOne({ + where: { id: cart.id }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + // Update the total amount in the cart + cart.updateTotal(); + await cartRepository.save(cart); + + res.cookie('cartId', cart.id); + responseSuccess(res, 201, 'cart updated successfully', { cart }); + return; + } + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/cartServices/readCart.ts b/src/services/cartServices/readCart.ts new file mode 100644 index 0000000..a891768 --- /dev/null +++ b/src/services/cartServices/readCart.ts @@ -0,0 +1,58 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const readCartService = async (req: Request, res: Response) => { + try { + const cartRepository = getRepository(Cart); + + if (req.user) { + const cart = await cartRepository.findOne({ + where: { + user: { + id: req.user.id, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + cart.user = cart.user.id as unknown as User; + responseSuccess(res, 200, 'Cart retrieved successfully', { cart }); + return; + } + + if (!req.user) { + if (!req.cookies.cartId) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + const cart = await cartRepository.findOne({ + where: { + id: req.cookies.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + responseSuccess(res, 200, 'Cart retrieved successfully', { cart }); + return; + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; \ No newline at end of file diff --git a/src/services/cartServices/removeProductInCart.ts b/src/services/cartServices/removeProductInCart.ts new file mode 100644 index 0000000..25bfe13 --- /dev/null +++ b/src/services/cartServices/removeProductInCart.ts @@ -0,0 +1,109 @@ +import { Request, Response } from 'express'; +import { CartItem } from '../../entities/CartItem'; +import { Cart } from '../../entities/Cart'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const removeProductInCartService = async (req: Request, res: Response) => { + try { + const cartItemRepository = getRepository(CartItem); + const cartRepository = getRepository(Cart); + + if (!req.params.id) { + responseError(res, 400, 'Cart item id is required'); + return; + } + + const cartItem = await cartItemRepository.findOne({ + where: { + id: req.params.id, + }, + relations: ['cart', 'cart.user'], + }); + + if (!cartItem) { + responseError(res, 404, 'Cart item not found'); + return; + } + + if (req.user) { + if (cartItem?.cart.user.id !== req.user.id) { + responseError(res, 401, 'You are not authorized to perform this action'); + return; + } + + await cartItemRepository.remove(cartItem as CartItem); + + const cart = await cartRepository.findOne({ + where: { + id: cartItem?.cart.id, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + if (cart.items.length === 0) { + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'cart removed successfully', { cart: [] }); + return; + } + + cart.updateTotal(); + await cartRepository.save(cart as Cart); + + cart.user = cart?.user.id as unknown as User; + + responseSuccess(res, 200, 'Product removed from cart successfully', { cart }); + return; + } + } + + if (!req.user) { + if (!req.params.id) { + responseError(res, 400, 'Cart item id is required'); + return; + } + + const cartItem = await cartItemRepository.findOne({ + where: { + id: req.params.id, + }, + relations: ['cart'], + }); + + if (!cartItem) { + responseError(res, 404, 'Cart item not found'); + return; + } + + await cartItemRepository.remove(cartItem); + + const cart = await cartRepository.findOne({ + where: { + id: cartItem.cart.id, + }, + relations: ['items', 'items.product'], + }); + + if (cart) { + if (cart.items.length === 0) { + await cartRepository.remove(cart); + + responseSuccess(res, 200, 'cart removed successfully', { cart: [] }); + return; + } + + cart.updateTotal(); + await cartRepository.save(cart); + + responseSuccess(res, 200, 'Product removed from cart successfully', { cart }); + return; + } + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/chatbotServices/chatBot.ts b/src/services/chatbotServices/chatBot.ts new file mode 100644 index 0000000..44b2e37 --- /dev/null +++ b/src/services/chatbotServices/chatBot.ts @@ -0,0 +1,30 @@ +import { configDotenv } from 'dotenv'; +import { Request, Response } from 'express'; +import { sendSuccessResponse, sendErrorResponse } from '../../utils/response.utils'; +import { manager } from '../../train'; + + +export const chatBot = async (req: Request, res: Response) => { + const userMessage = req.body.message; + + try { + if(!userMessage){ + return sendErrorResponse(res, 400, 'No user message'); + } + const result = await manager.process('en', userMessage); + const intent = result.intent; + + if (result.answer || intent !== 'None') { + return sendSuccessResponse(res, 200, "", result.answer) + } + + else { + return sendSuccessResponse(res, 200, "Sorry, I am not sure what you mean. Can you rephrase?", result.answer) + } + + } catch (error) { + console.error(error); + return sendErrorResponse(res, 500, (error as Error).message); + + } + }; \ No newline at end of file diff --git a/src/services/couponServices/accessAllCoupon.ts b/src/services/couponServices/accessAllCoupon.ts new file mode 100644 index 0000000..9266a44 --- /dev/null +++ b/src/services/couponServices/accessAllCoupon.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { User } from '../../entities/User'; + +export const accessAllCouponService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Retrieve the user by id + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id } }); + + if (!user) { + console.log('User not found with id:', id); + return responseError(res, 404, 'User not found'); + } + + // Retrieve all coupons for the user + const couponRepository = getRepository(Coupon); + const coupons = await couponRepository.find({ + where: { vendor: { id: user.id } }, + relations: ['product'], + }); + + if (!coupons.length) { + console.log('No coupons found for user with id:', id); + return responseError(res, 404, 'No coupons found'); + } + + return responseSuccess(res, 200, 'Coupons retrieved successfully', coupons); + } catch (error: any) { + console.log('Error retrieving all coupons:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/buyerApplyCoupon.ts b/src/services/couponServices/buyerApplyCoupon.ts new file mode 100644 index 0000000..85762f6 --- /dev/null +++ b/src/services/couponServices/buyerApplyCoupon.ts @@ -0,0 +1,87 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { Cart } from '../../entities/Cart'; +import { CartItem } from '../../entities/CartItem'; + +export const buyerApplyCouponService = async (req: Request, res: Response) => { + try { + const { couponCode } = req.body; + + if (!couponCode) return res.status(400).json({ message: 'Coupon Code is required' }); + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ + where: { code: couponCode }, + relations: ['product'], + }); + + if (!coupon) return res.status(404).json({ message: 'Invalid Coupon Code' }); + + if (coupon) { + if (coupon.expirationDate && coupon.expirationDate < new Date()) { + return res.status(400).json({ message: 'Coupon is expired' }); + } + + if (coupon.usageTimes == coupon.maxUsageLimit) { + return res.status(400).json({ message: 'Coupon Discount Ended' }); + } + } + const couponProductId = coupon.product.id; + + const cartRepository = getRepository(Cart); + let cart = await cartRepository.findOne({ + where: { user: { id: req.user?.id }, isCheckedOut: false }, + relations: ['items', 'items.product'], + }); + + if (!cart) return res.status(400).json({ message: "You don't have a product in cart" }); + + const cartItemRepository = getRepository(CartItem); + const couponCartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: couponProductId }, + }, + relations: ['product'], + }); + + if (!couponCartItem) return res.status(404).json({ message: 'No product in Cart with that coupon code' }); + + let amountReducted; + if (coupon.discountType === 'percentage') { + const reduction = (couponCartItem.product.newPrice * coupon.discountRate) / 100; + amountReducted = reduction; + couponCartItem.newPrice = couponCartItem.product.newPrice - reduction; + + await cartItemRepository.save(couponCartItem); + } else { + amountReducted = coupon.discountRate; + couponCartItem.newPrice = couponCartItem.product.newPrice - amountReducted; + await cartItemRepository.save(couponCartItem); + } + + cart = await cartRepository.findOne({ where: { id: cart.id }, relations: ['items', 'items.product'] }); + if (cart) { + cart.updateTotal(); + await cartRepository.save(cart); + } + + coupon.usageTimes += 1; + + if (req.user?.id) { + coupon.usedBy.push(req.user?.id); + } + + await couponRepository.save(coupon); + + return res + .status(200) + .json({ + message: `Coupon Code successfully activated discount on product: ${couponCartItem.product.name}`, + amountDiscounted: amountReducted, + }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/couponServices/createCouponService.ts b/src/services/couponServices/createCouponService.ts new file mode 100644 index 0000000..a824ddf --- /dev/null +++ b/src/services/couponServices/createCouponService.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { validateCoupon } from '../../helper/couponValidator'; +import { User } from '../../entities/User'; +import { Product } from '../../entities/Product'; + +export const createCouponService = async (req: Request, res: Response) => { + try { + const { error } = validateCoupon(req.body); + if (error) { + console.log('Validation Error creating coupon:\n', error); + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const { code, discountRate, expirationDate, maxUsageLimit, discountType, product: productId } = req.body; + const { id: vendorId } = req.params; + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id: vendorId } }); + if (!user) { + console.log('Error creating coupon: User not found', user); + return responseError(res, 404, 'User not found'); + } + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id: productId } }); + if (!product) { + console.log('Error creating coupon: Product not found', product); + return responseError(res, 403, 'Product not found'); + } + + const couponRepository = getRepository(Coupon); + const existingCoupon = await couponRepository.findOne({ where: { code } }); + if (existingCoupon) { + return responseError(res, 402, 'Coupon code already exists'); + } + + const newCoupon = new Coupon(); + newCoupon.code = code; + newCoupon.discountRate = discountRate; + newCoupon.expirationDate = expirationDate; + newCoupon.maxUsageLimit = maxUsageLimit; + newCoupon.discountType = discountType; + newCoupon.product = product; + newCoupon.vendor = user; + + await couponRepository.save(newCoupon); + responseSuccess(res, 201, 'Coupon created successfully'); + } catch (error: any) { + console.log('Error creating coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/deleteCoupon.ts b/src/services/couponServices/deleteCoupon.ts new file mode 100644 index 0000000..c984d9e --- /dev/null +++ b/src/services/couponServices/deleteCoupon.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; + +export const deleteCouponService = async (req: Request, res: Response) => { + try { + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code: req.body.code } }); + + if (!coupon) { + console.log('Invalid coupon.'); + return responseError(res, 404, 'Invalid coupon'); + } + + await couponRepository.remove(coupon); + + return responseSuccess(res, 200, 'Coupon deleted successfully'); + } catch (error: any) { + console.log('Error deleting coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/readCoupon.ts b/src/services/couponServices/readCoupon.ts new file mode 100644 index 0000000..47e12ea --- /dev/null +++ b/src/services/couponServices/readCoupon.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; + +export const readCouponService = async (req: Request, res: Response) => { + try { + const { code } = req.params; + if (!code) return responseError(res, 400, 'coupon code is required'); + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code: code } }); + + if (!coupon) { + return responseError(res, 404, 'Invalid coupon'); + } + + return responseSuccess(res, 200, 'Coupon retrieved successfully', coupon); + } catch (error: any) { + console.log('Error retrieving coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/updateService.ts b/src/services/couponServices/updateService.ts new file mode 100644 index 0000000..26aeef6 --- /dev/null +++ b/src/services/couponServices/updateService.ts @@ -0,0 +1,59 @@ +import { Coupon } from '../../entities/coupon'; +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { validateCouponUpdate } from '../../helper/couponValidator'; +import { Product } from '../../entities/Product'; + +export const updateCouponService = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { error } = validateCouponUpdate(req.body); + if (error) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code } }); + if (coupon) { + if (req.body.code !== undefined) { + const existtCoupon = await couponRepository.findOne({ where: { code: req.body.code } }); + if (existtCoupon) return responseError(res, 400, 'Coupon code already exists'); + if (req.body.code === coupon.code) return responseError(res, 400, 'Coupon code already up to date'); + coupon.code = req.body.code; + } + if (req.body.discountRate !== undefined) { + coupon.discountRate = req.body.discountRate; + } + if (req.body.expirationDate !== undefined) { + coupon.expirationDate = req.body.expirationDate; + } + if (req.body.maxUsageLimit !== undefined) { + coupon.maxUsageLimit = req.body.maxUsageLimit; + } + if (req.body.discountType !== undefined) { + coupon.discountType = req.body.discountType; + } + if (req.body.product !== undefined) { + const { id } = req.body.product; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id } }); + if (!product) { + console.log('Error updating coupon: Product not found', product); + return responseError(res, 404, 'Product not found'); + } + + coupon.product = product; + } + + await couponRepository.save(coupon); + return responseSuccess(res, 200, 'Coupon updated successfully', coupon); + } else { + console.log('Error updating coupon: Coupon not found', coupon); + return responseError(res, 404, 'Coupon not found'); + } + } catch (error: any) { + console.log('Error while updating coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/feedbackServices/adminDeleteFeedback.ts b/src/services/feedbackServices/adminDeleteFeedback.ts new file mode 100644 index 0000000..7bf6261 --- /dev/null +++ b/src/services/feedbackServices/adminDeleteFeedback.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const adminDeleteFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + + try { + const feedbackRepository = getRepository(Feedback); + const feedback = await feedbackRepository.findOne({ + where: { id: feedbackId }, + }); + + if (!feedback) { + return responseError(res, 404, 'Feedback not found'); + } + + await feedbackRepository.remove(feedback); + + return responseSuccess(res, 200, 'Feedback successfully removed'); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/createFeedback.ts b/src/services/feedbackServices/createFeedback.ts new file mode 100644 index 0000000..fa731f3 --- /dev/null +++ b/src/services/feedbackServices/createFeedback.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { Order } from '../../entities/Order'; + +interface AuthRequest extends Request { + user?: User; +} + +export const createFeedbackService = async (req: Request, res: Response) => { + const { productId } = req.params; + const { comment, orderId } = req.body; + + try { + const feedbackRepository = getRepository(Feedback); + const productRepository = getRepository(Product); + const orderRepository = getRepository(Order); + if (!orderId) { + return responseError(res, 404, `Your feedback can't be recorded at this time Your order doesn't exist `); + } + const product = await productRepository.findOne({ where: { id: productId } }); + if (!product) { + return responseError(res, 404, `Your feedback can't be recorded at this time product not found`); + } + const order = await orderRepository.findBy({ id: orderId, orderStatus: 'completed', buyer: { id: req.user?.id }, orderItems: { product: { id: productId } } }) + if (!order.length) { + return responseError(res, 404, `Your feedback can't be recorded at this time Your order haven't been completed yet or doesn't contain this product`); + } + + const feedback = new Feedback(); + feedback.comment = comment; + feedback.user = req.user as User; + feedback.product = product; + + await feedbackRepository.save(feedback); + + return responseSuccess(res, 201, 'Feedback created successfully', feedback); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/deleteFeedback.ts b/src/services/feedbackServices/deleteFeedback.ts new file mode 100644 index 0000000..5de4ea0 --- /dev/null +++ b/src/services/feedbackServices/deleteFeedback.ts @@ -0,0 +1,27 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const deleteFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + + try { + const feedbackRepository = getRepository(Feedback); + const feedback = await feedbackRepository.findOne({ + where: { id: feedbackId, + user: {id: req?.user?.id }, + } + }); + + if (!feedback) { + return responseError(res, 404, 'Feedback not found'); + } + + await feedbackRepository.remove(feedback); + + return responseSuccess(res, 200, 'Feedback successfully removed'); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/feedbackServices/updateFeedback.ts b/src/services/feedbackServices/updateFeedback.ts new file mode 100644 index 0000000..18258c2 --- /dev/null +++ b/src/services/feedbackServices/updateFeedback.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Feedback } from '../../entities/Feedback'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { User } from '../../entities/User'; + +export const updateFeedbackService = async (req: Request, res: Response) => { + const { feedbackId } = req.params; + const { comment } = req.body; + + try { + const feedbackRepository = getRepository(Feedback); + + const feedback = await feedbackRepository.findOne({ + where: { + id: feedbackId, + user: { id: req?.user?.id }, + }, + }); + + if (!feedback) { + return responseError(res, 404, 'You are not allowed to remove this feedback or you are not allowed to edit this feedback'); + } + + feedback.comment = comment; + await feedbackRepository.save(feedback); + + return responseSuccess(res, 200, 'Feedback updated successfully', feedback); + } catch (error) { + return responseError(res, 500, 'Server error'); + } +}; diff --git a/src/services/index.ts b/src/services/index.ts index a7b7863..f31e750 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,11 +1,52 @@ - -export * from "./userServices/sendResetPasswordLinkService"; -export * from "./userServices/userPasswordResetService"; -export * from "./userServices/userRegistrationService"; -export * from "./userServices/userValidationService"; +export * from './userServices/sendResetPasswordLinkService'; +export * from './userServices/userPasswordResetService'; +export * from './userServices/userRegistrationService'; +export * from './userServices/userValidationService'; export * from './userServices/userEnableTwoFactorAuth'; export * from './userServices/userDisableTwoFactorAuth'; export * from './userServices/userValidateOTP'; export * from './userServices/userLoginService'; export * from './userServices/userResendOTP'; export * from './userServices/logoutServices'; +export * from './userServices/userProfileUpdateServices'; + +// Vendor product services +export * from './productServices/createProduct'; +export * from './productServices/updateProduct'; +export * from './productServices/removeProductImage'; +export * from './productServices/readProduct'; +export * from './productServices/deleteProduct'; +export * from './productServices/getRecommendedProductsService'; +export * from './productServices/listAllProductsService'; +export * from './productServices/productStatus'; +export * from './productServices/viewSingleProduct'; +export * from './productServices/searchProduct'; +export * from './productServices/payment'; + +// Buyer wishlist services +export * from './wishListServices/addProduct'; +export * from './wishListServices/getProducts'; +export * from './wishListServices/removeProducts'; +export * from './wishListServices/clearAll'; + +// cart managment +export * from './cartServices/createCart'; +export * from './cartServices/readCart'; +export * from './cartServices/removeProductInCart'; +export * from './cartServices/clearCart'; + +// vendor order management +export * from './vendorOrderServices/readVendorOrder'; +export * from './vendorOrderServices/updateVendorOrder'; + +// vendor order management +export * from './adminOrderServices/readOrder'; +export * from './adminOrderServices/updateOrder'; + +// Nofication management +export * from './notificationServices/getNotifications'; +export * from './notificationServices/deleteNotification'; +export * from './notificationServices/updateNotification'; + +// chatbot +export * from './chatbotServices/chatBot'; \ No newline at end of file diff --git a/src/services/notificationServices/deleteNotification.ts b/src/services/notificationServices/deleteNotification.ts new file mode 100644 index 0000000..1ef3c04 --- /dev/null +++ b/src/services/notificationServices/deleteNotification.ts @@ -0,0 +1,96 @@ +import { Request, Response } from 'express'; +import { Notification } from '../../entities/Notification'; +import { NotificationItem } from '../../entities/NotificationItem'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { getIO } from '../../utils/socket'; +import { getNotifications } from '../../utils/getNotifications'; + +export const deleteSelectedNotificationService = async (req: Request, res: Response) => { + try { + let notificationIds: string[] = req.body.notificationIds; + notificationIds = Array.from(new Set(notificationIds)); + + if (!notificationIds.length) { + return responseError(res, 400, 'Please provide Notification IDs to delete'); + } + + const notificationRepo = getRepository(Notification); + const notificationItemRepo = getRepository(NotificationItem); + + const notificationItems: string[] = []; + + for (const id of notificationIds) { + try { + const notificationItem = await notificationItemRepo.findOne({ + where: { + id: id, + notification: { + user: { + id: req.user?.id + } + } + } + }); + + if (notificationItem) { + await notificationItemRepo.remove(notificationItem); + notificationItems.push(id); + } + } catch (error) { + continue; + } + } + + const notification = await notificationRepo + .findOne({ + where: { user: { id: req.user?.id } }, + relations: ['allNotifications'] + }); + + if (notification) { + notification.updateUnread(); + await notificationRepo.save(notification); + } + + getIO().emit('notification', { + action: `${req.user?.email} notification`, + notifications: await getNotifications(req.user!.id!) + }); + + return responseSuccess(res, 200, `${notificationItems.length} of ${notificationIds.length} Notification(s) was successfully deleted.`); + + } catch (error) { + return responseError(res, 500, (error as Error).message); + } +}; + +export const deleteAllNotificationService = async (req: Request, res: Response) => { + try { + const notificationRepo = getRepository(Notification); + + + const notification = await notificationRepo + .findOne({ + where: { user: { id: req.user?.id } }, + relations: ['allNotifications', 'user'] + }); + + if (!notification || !notification.allNotifications.length ) { + responseError(res, 404, "User doesn't have notifications"); + return; + } + + await notificationRepo.remove(notification); + + getIO().emit('notification', { + action: `${req.user?.email} notification`, + notifications: await getNotifications(req.user!.id!) + }); + + return responseSuccess(res, 200, `All Notifications was successfully deleted.`); + + } catch (error) { + return responseError(res, 500, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/notificationServices/getNotifications.ts b/src/services/notificationServices/getNotifications.ts new file mode 100644 index 0000000..a291c33 --- /dev/null +++ b/src/services/notificationServices/getNotifications.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { Notification } from '../../entities/Notification'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; + +export const getNotificationsService = async(req: Request, res: Response) => { + try { + const notificationRepo = getRepository(Notification); + + const notification = await notificationRepo + .findOne({where: {user: { id: req.user?.id }}, relations: ['user','allNotifications'], + order: { + createdAt: 'DESC', + }, + }); + + if(!notification){ + return responseSuccess(res, 200, `User doesn't have any notifications.`, { notificationDetails: {} }); + } + + const notificationDetails = { + id: notification.id, + notifications: notification.allNotifications, + unRead: notification.unRead + }; + + return responseSuccess(res, 200, 'Notifications retrieved successfully', { notificationDetails }); + } catch (error) { + return responseError(res, 500, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/notificationServices/updateNotification.ts b/src/services/notificationServices/updateNotification.ts new file mode 100644 index 0000000..c8295d9 --- /dev/null +++ b/src/services/notificationServices/updateNotification.ts @@ -0,0 +1,113 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { NotificationItem } from '../../entities/NotificationItem'; +import { Notification } from '../../entities/Notification'; +import { getIO } from '../../utils/socket'; +import { getNotifications } from '../../utils/getNotifications'; + +export const updateNotificationsService = async (req: Request, res: Response) => { + try { + let notificationIds: string[] = req.body.notificationIds; + notificationIds = Array.from(new Set(notificationIds)); + + if (!notificationIds.length) { + return responseError(res, 400, 'Please provide Notification IDs to update'); + } + + const notificationRepo = getRepository(Notification); + const notificationItemRepo = getRepository(NotificationItem); + + const notificationItems: string[] = []; + + for (const id of notificationIds) { + try { + const notificationItem = await notificationItemRepo.findOne({ + where: { + id: id, + notification: { + user: { + id: req.user?.id + } + } + } + }); + + if (notificationItem) { + notificationItem.isRead = true; + await notificationItemRepo.save(notificationItem); + notificationItems.push(id); + } + } catch (error) { + continue; + } + } + + const notification = await notificationRepo + .findOne({ + where: { user: { id: req.user?.id } }, + relations: ['allNotifications', 'user'] + }); + + if (notification) { + notification.updateUnread(); + await notificationRepo.save(notification); + } + + getIO().emit('notification', { + action: `${req.user?.email} notification`, + notifications: await getNotifications(req.user!.id!) + }); + + return responseSuccess(res, 200, `${notificationItems.length} of ${notificationIds.length} Notification(s) was successfully updated.`); + + } catch (error) { + return responseError(res, 500, (error as Error).message); + } +}; + +export const updateAllNotificationsService = async (req: Request, res: Response) => { + try { + + const notificationRepo = getRepository(Notification); + const notificationItemRepo = getRepository(NotificationItem); + + const notification = await notificationRepo + .findOne({ + where: { + user: { + id: req.user?.id + }, + allNotifications: { + isRead: false + } + }, + relations: ['allNotifications'] + }); + + if (!notification || !notification.allNotifications.length) { + responseSuccess(res, 200, "User doesn't have any unread notifications."); + return; + } + + for (const notificationItem of notification.allNotifications) { + notificationItem.isRead = true; + await notificationItemRepo.save(notificationItem); + } + + if (notification) { + notification.updateUnread(); + await notificationRepo.save(notification); + } + + getIO().emit('notification', { + action: `${req.user?.email} notification`, + notifications: await getNotifications(req.user!.id!) + }); + + return responseSuccess(res, 200, `All your unread notifications was successfully updated as read.`, notification); + + } catch (error) { + return responseError(res, 500, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/orderServices/createOrder.ts b/src/services/orderServices/createOrder.ts new file mode 100644 index 0000000..6bec977 --- /dev/null +++ b/src/services/orderServices/createOrder.ts @@ -0,0 +1,189 @@ +import { Request, Response } from 'express'; +import { getRepository, getManager } from 'typeorm'; +import { Order } from '../../entities/Order'; +import { OrderItem } from '../../entities/OrderItem'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { Cart } from '../../entities/Cart'; +import { Transaction } from '../../entities/transaction'; +import { responseError, sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import sendMail from '../../utils/sendOrderMail'; +import { VendorOrders } from '../../entities/vendorOrders'; +import { CartItem } from '../../entities/CartItem'; +import { VendorOrderItem } from '../../entities/VendorOrderItem'; +import { sendNotification } from '../../utils/sendNotification'; + +export const createOrderService = async (req: Request, res: Response) => { + const { cartId, address } = req.body; + const buyerId = req.user?.id; + + try { + const userRepository = getRepository(User); + const productRepository = getRepository(Product); + const cartRepository = getRepository(Cart); + + const buyer = await userRepository.findOne({ where: { id: buyerId } }); + if (!buyer) { + return responseError(res, 404, 'Buyer not found'); + } + + const cart = await cartRepository.findOne({ + where: { + user: { + id: buyerId, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart || cart.items.length === 0) { + return sendErrorResponse(res, 400, 'Cart is empty or already checked out'); + } + + let totalPrice = 0; + const orderItems: OrderItem[] = []; + + for (const item of cart.items) { + const product = item.product; + + if (product.quantity < item.quantity) { + return sendErrorResponse(res, 400, `Not enough ${product.name} in stock`); + } + + totalPrice += product.newPrice * item.quantity; + product.quantity -= item.quantity; + + const orderItem = new OrderItem(); + orderItem.product = product; + orderItem.price = product.newPrice; + orderItem.quantity = item.quantity; + orderItems.push(orderItem); + } + const newOrder = new Order(); + newOrder.buyer = buyer; + newOrder.totalPrice = totalPrice; + newOrder.orderItems = orderItems; + newOrder.quantity = cart.items.reduce((acc, item) => acc + item.quantity, 0); + newOrder.orderDate = new Date(); + newOrder.address = `${address.country}, ${address.city}, ${address.street}`; + + await getManager().transaction(async transactionalEntityManager => { + for (const item of cart.items) { + const product = item.product; + await transactionalEntityManager.save(Product, product); + } + + await transactionalEntityManager.save(User, buyer); + + await transactionalEntityManager.save(Order, newOrder); + for (const orderItem of orderItems) { + orderItem.order = newOrder; + await transactionalEntityManager.save(OrderItem, orderItem); + } + + const orderTransaction = new Transaction(); + orderTransaction.user = buyer; + orderTransaction.order = newOrder; + orderTransaction.amount = totalPrice; + orderTransaction.type = 'debit'; + orderTransaction.description = 'Purchase of products'; + await transactionalEntityManager.save(Transaction, orderTransaction); + + cart.isCheckedOut = true; + await transactionalEntityManager.save(Cart, cart); + }); + + const orderResponse = { + id: newOrder.id, + fullName: `${newOrder.buyer.firstName} ${newOrder.buyer.lastName}`, + email: newOrder.buyer.email, + products: orderItems.map(item => ({ + name: item.product.name, + newPrice: item.price, + quantity: item.quantity, + })), + totalAmount: newOrder.totalPrice, + quantity: newOrder.quantity, + orderDate: newOrder.orderDate, + address: newOrder.address, + }; + + const message = { + subject: 'Order created successfully', + ...orderResponse, + }; + + await sendMail(message); + + // separate order by each vendor getting order related to his products + await saveVendorRelatedOrder(newOrder, cart.items); + + return sendSuccessResponse(res, 201, 'Order created successfully', orderResponse); + } catch (error) { + return sendErrorResponse(res, 500, (error as Error).message); + } +}; + +const saveVendorRelatedOrder = async (order: Order, CartItem: CartItem[]) => { + try { + for (const item of CartItem) { + const productRepository = getRepository(Product); + let sendNotif: boolean = false + + const product = await productRepository.findOne({ + where: { + id: item.product.id, + }, + relations: ['vendor'], + }); + + if (!product) return; + + const orderItem = new VendorOrderItem(); + orderItem.product = product; + orderItem['price/unit'] = product.newPrice; + orderItem.quantity = item.quantity; + + const vendorOrdersRepository = getRepository(VendorOrders); + let vendorOrders = await vendorOrdersRepository.findOne({ + where: { + vendor: { + id: product.vendor.id, + }, + order: { + id: order.id, + }, + }, + relations: ['vendorOrderItems'], + }); + + if (vendorOrders) { + vendorOrders.totalPrice = Number(vendorOrders.totalPrice) + +product.newPrice * +item.quantity; + vendorOrders.vendorOrderItems = [...vendorOrders.vendorOrderItems, orderItem]; + } else { + const newVendorOrders = new VendorOrders(); + newVendorOrders.vendor = product.vendor; + newVendorOrders.vendorOrderItems = [orderItem]; + newVendorOrders.order = order; + newVendorOrders.totalPrice = product.newPrice * item.quantity; + vendorOrders = newVendorOrders; + + sendNotif = true; + } + + await vendorOrdersRepository.save(vendorOrders); + + if (sendNotif) { + await sendNotification({ + content: `Buyer "${vendorOrders.order.buyer.firstName} ${vendorOrders.order.buyer.lastName}" has added one of your products to their order. Please confirm that you'll be able to deliver it.`, + type: 'order', + user: vendorOrders.vendor, + link: `/product/vendor/orders/${vendorOrders.id}` + }); + } + } + } catch (error) { + console.log((error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/orderServices/getOrderService.ts b/src/services/orderServices/getOrderService.ts new file mode 100644 index 0000000..17a29a8 --- /dev/null +++ b/src/services/orderServices/getOrderService.ts @@ -0,0 +1,119 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { Order } from '../../entities/Order'; +import { OrderItem } from '../../entities/OrderItem'; + +// Example usage: + +export const getOrdersService = async (req: Request, res: Response) => { + try { + const orderRepository = getRepository(Order); + const buyerId = req.user?.id; + + const orders = await orderRepository.find({ + where: { + buyer: { + id: buyerId, + }, + }, + relations: ['buyer', 'orderItems', 'orderItems.product'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + if (!orders || orders.length === 0) { + return responseSuccess(res, 404, `You haven't made any orders yet`, { orders: [] }); + } + + const sanitezedResponse = orders.map(order => ({ + id: order.id, + totalPrice: order.totalPrice, + orderStatus: order.orderStatus, + quantity: order.quantity, + address: order.address, + orderDate: order.orderDate, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + accountBalance: order.buyer.accountBalance, + }, + orderItems: order.orderItems.map((item: OrderItem) => ({ + id: item.id, + price: item.price, + quantity: item.quantity, + product: { + id: item.product.id, + name: item.product.name, + description: item.product.description, + images: item.product.images, + price: item.product.newPrice, + expirationDate: item.product.expirationDate, + }, + })), + })); + responseSuccess(res, 200, 'Orders retrieved successfully', { orders: sanitezedResponse }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; + +export const getOrderService = async (req: Request, res: Response) => { + try { + const orderId = req.params.orderId; + const orderRepository = getRepository(Order); + const buyerId = req.user?.id; + + const order = await orderRepository.findOne({ + where: { + id: orderId, + buyer: { + id: buyerId, + }, + }, + relations: ['buyer', 'orderItems', 'orderItems.product'] + }); + + if (!order) { + return responseSuccess(res, 404, `Order not found.`); + } + + const sanitizedResponse = { + id: order.id, + totalPrice: order.totalPrice, + orderStatus: order.orderStatus, + quantity: order.quantity, + address: order.address, + orderDate: order.orderDate, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + accountBalance: order.buyer.accountBalance, + }, + orderItems: order.orderItems.map((item: OrderItem) => ({ + id: item.id, + price: item.price, + quantity: item.quantity, + product: { + id: item.product.id, + name: item.product.name, + description: item.product.description, + images: item.product.images, + price: item.product.newPrice, + expirationDate: item.product.expirationDate, + }, + })), + }; + + responseSuccess(res, 200, 'Order retrieved successfully', { order: sanitizedResponse }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/orderServices/getOrderTransactionHistory.ts b/src/services/orderServices/getOrderTransactionHistory.ts new file mode 100644 index 0000000..6bd0b17 --- /dev/null +++ b/src/services/orderServices/getOrderTransactionHistory.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Transaction } from '../../entities/transaction'; +import { sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import { OrderItem } from '../../entities/OrderItem'; + +export const getTransactionHistoryService = async (req: Request, res: Response) => { + const userId = req.user?.id; + + try { + const transactionRepository = getRepository(Transaction); + const transactions = await transactionRepository.find({ + where: { user: { id: userId } }, + order: { createdAt: 'DESC' }, + relations: ['order'], + }); + + if (!transactions || transactions.length === 0) { + return sendErrorResponse(res, 404, 'No transaction history found'); + } + + const transactionHistory = transactions.map(transaction => ({ + id: transaction.id, + amount: transaction.amount, + type: transaction.type, + description: transaction.description, + createdAt: transaction.createdAt, + order: transaction.order + ? { + id: transaction.order.id, + totalPrice: transaction.order.totalPrice, + orderDate: transaction.order.orderDate, + address: transaction.order.address, + } + : null, + })); + + return sendSuccessResponse(res, 200, 'Transaction history retrieved successfully', transactionHistory); + } catch (error) { + return sendErrorResponse(res, 500, (error as Error).message); + } +}; diff --git a/src/services/orderServices/updateOrderService.ts b/src/services/orderServices/updateOrderService.ts new file mode 100644 index 0000000..4ff7c3a --- /dev/null +++ b/src/services/orderServices/updateOrderService.ts @@ -0,0 +1,170 @@ +import { Request, Response } from 'express'; +import { getManager, EntityManager, Repository, getRepository } from 'typeorm'; +import { Order } from '../../entities/Order'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { OrderItem } from '../../entities/OrderItem'; +import { Transaction } from '../../entities/transaction'; +import { responseError, sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import sendMail from '../../utils/sendOrderMail'; +import { sendNotification } from '../../utils/sendNotification'; +import { VendorOrders } from '../../entities/vendorOrders'; +interface OrderStatusType { + orderStatus: + | 'order placed' + | 'cancelled' + | 'awaiting shipment' + | 'in transit' + | 'delivered' + | 'received' + | 'returned'; +} +export const updateOrderService = async (req: Request, res: Response) => { + const { orderId } = req.params; + const { orderStatus } = req.body; + + try { + await getManager().transaction(async (transactionalEntityManager: EntityManager) => { + const orderRepository: Repository = transactionalEntityManager.getRepository(Order); + const productRepository: Repository = transactionalEntityManager.getRepository(Product); + const userRepository: Repository = transactionalEntityManager.getRepository(User); + const orderItemRepository: Repository = transactionalEntityManager.getRepository(OrderItem); + const transactionRepository: Repository = transactionalEntityManager.getRepository(Transaction); + + const buyerId = req.user?.id; + if (!buyerId) { + throw new Error('Unauthorized'); + } + + // Fetch order and related entities + const order: Order | null = await orderRepository.findOne({ + where: { id: orderId, buyer: { id: buyerId } }, + relations: ['orderItems', 'orderItems.product', 'buyer'], + }); + + if (!order) { + return sendErrorResponse(res, 404, 'Order not found'); + } + // Check if order can be updated + if (isOrderFinalStatus(order.orderStatus)) { + return sendErrorResponse(res, 401, `Order cannot be updated once it is ${order.orderStatus}`); + } + + // Handle order status transitions + if (orderStatus !== undefined && order.orderStatus !== orderStatus) { + switch (orderStatus) { + case 'cancelled': + case 'returned': + if (order.orderStatus !== 'delivered') { + await processRefund(order, transactionalEntityManager); + } + break; + default: + break; + } + + order.orderStatus = orderStatus; + } + + // Save updated order status + await orderRepository.save(order); + + if (orderStatus === 'received') { + const admins = await getRepository(User).find({ + where: { + role: 'ADMIN' + } + }); + + admins.forEach( async (admin) => { + await sendNotification({ + content: `The Buyer named "${order.buyer.firstName} ${order.buyer.lastName}", has confirmed that they have successfully received their order.`, + type: 'order', + user: admin, + link: `/product/admin/orders/${order.id}` + }); + }); + } + + const vendorOrders = await getRepository(VendorOrders).find({ + where: { + order: { + id: order.id + } + }, + relations: { + vendor: true + } + }); + + vendorOrders.forEach(async (vendorOrder) => { + await sendNotification({ + content: `The Buyer named "${order.buyer.firstName} ${order.buyer.lastName}", has marked their order as "${orderStatus}". Please ensure that you update the order status on your side as well.`, + type: 'order', + user: vendorOrder.vendor, + link: `/product/vendor/orders/${vendorOrder.id}` + }); + }); + + // Prepare response data + const orderResponse = { + fullName: `${order.buyer.firstName} ${order.buyer.lastName}`, + email: order.buyer.email, + products: order.orderItems.map((item: OrderItem) => ({ + name: item.product.name, + newPrice: item.price, + quantity: item.quantity, + })), + totalAmount: order.totalPrice, + quantity: order.quantity, + orderDate: order.orderDate, + address: order.address, + }; + + // Send email notification + const message = { + subject: 'Order updated successfully', + ...orderResponse, + }; + await sendMail(message); + + // Respond with success + return sendSuccessResponse(res, 200, 'Order updated successfully', orderResponse); + }); + } catch (error) { + console.error('Error updating order:', (error as Error).message); + return sendErrorResponse(res, 500, (error as Error).message); + } +}; + +async function processRefund (order: Order, entityManager: EntityManager) { + const buyer = order.buyer; + + // Refund buyer + await entityManager.save(buyer); + + // Record refund transaction + const refundTransaction = new Transaction(); + refundTransaction.user = buyer; + refundTransaction.order = order; + refundTransaction.amount = order.totalPrice; + refundTransaction.type = 'credit'; + refundTransaction.description = 'Refund for cancelled or returned order'; + await entityManager.save(refundTransaction); + + // Return products to store + for (const orderItem of order.orderItems) { + const product = orderItem.product; + product.quantity += orderItem.quantity; + await entityManager.save(product); + } + + // Clear order details + order.orderItems = []; + order.totalPrice = 0; + order.quantity = 0; +} + +function isOrderFinalStatus (status: string): boolean { + return ['cancelled', 'delivered', 'returned', 'completed'].includes(status); +} \ No newline at end of file diff --git a/src/services/productServices/createProduct.ts b/src/services/productServices/createProduct.ts new file mode 100644 index 0000000..44918d8 --- /dev/null +++ b/src/services/productServices/createProduct.ts @@ -0,0 +1,104 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; +import { Category } from '../../entities/Category'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const createProductService = async (req: Request, res: Response) => { + try { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const existingProduct = await getRepository(Product).findOne({ + where: { + name: req.body.name, + vendor: { + id: req.user?.id, + }, + }, + }); + + if (existingProduct) { + return res.status(409).json({ status: 'error', error: 'Its looks like Product already exists' }); + } + + const files: any = req.files; + + if (files.length < 2) { + return res.status(400).json({ status: 'error', error: 'Please upload more than one image' }); + } + if (files.length > 6) { + return res.status(400).json({ status: 'error', error: 'Product cannot have more than 6 images' }); + } + + const imageUrls: string[] = []; + for (const file of files) { + const image = file.path; + const link = await cloudinary.uploader.upload(image); + imageUrls.push(link.secure_url); + } + + const product = new Product(); + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + product.quantity = req.body.quantity; + product.images = imageUrls; + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + product.vendor = req.user as User; + + const categoryRepository = getRepository(Category); + let categories = []; + if (typeof req.body.categories === 'string') { + let category = await categoryRepository.findOne({ + where: { name: req.body.categories.toLowerCase() }, + }); + if (!category) { + category = new Category(); + category.name = req.body.categories.toLowerCase(); + category = await categoryRepository.save(category); + } + categories.push(category); + } else { + categories = await Promise.all( + req.body.categories.map(async (categoryName: string) => { + let category = await categoryRepository.findOne({ where: { name: categoryName.toLowerCase() } }); + if (!category) { + category = new Category(); + category.name = categoryName.toLowerCase(); + await categoryRepository.save(category); + } + return category; + }) + ); + } + product.categories = categories; + + const productRepository = getRepository(Product); + const savedProduct = await productRepository.save(product); + + product.vendor = product.vendor.id as unknown as User; + return res.status(201).json({ + status: 'success', + data: { + message: 'Product created successfully', + product: { ...savedProduct }, + }, + }); + } catch (error) { + res.status(400).json({ message: (error as Error).message }); + } +}; \ No newline at end of file diff --git a/src/services/productServices/deleteProduct.ts b/src/services/productServices/deleteProduct.ts new file mode 100644 index 0000000..068c4c9 --- /dev/null +++ b/src/services/productServices/deleteProduct.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const deleteProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id, + }, + }, + }); + + if (product) { + await productRepository.remove(product); + return responseSuccess(res, 200, 'Product successfully deleted'); + } + + return responseError(res, 404, 'Product not found'); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/getRecommendedProductsService.ts b/src/services/productServices/getRecommendedProductsService.ts new file mode 100644 index 0000000..533dcd9 --- /dev/null +++ b/src/services/productServices/getRecommendedProductsService.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Product } from '../../entities/Product'; + +interface conditionDoc { + categories: any[] | null; + vendor: any | null; +} + +export const getRecommendedProductsService = async (req: Request, res: Response) => { + try { + // Define pagination parameters + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + const condition: conditionDoc = { + categories: null, + vendor: null, + }; + + if (req.query.categories) { + const categoryIds = Array.isArray(req.query.categories) ? req.query.categories : [req.query.categories]; + condition.categories = categoryIds; + } + if (req.query.vendor) condition.vendor = req.query.vendor; + + const productRepository = getRepository(Product); + const productsQuery = productRepository + .createQueryBuilder('product') + .leftJoinAndSelect('product.categories', 'category') + .leftJoinAndSelect('product.vendor', 'vendor') + .leftJoinAndSelect('product.feedbacks', 'feedbacks') + .where('1 = 1'); + + if (condition.categories && condition.categories.length > 0) { + productsQuery.andWhere('category.id IN (:...categories)', { categories: condition.categories }); + } + if (condition.vendor) { + productsQuery.andWhere('vendor.id = :vendorId', { vendorId: condition.vendor }); + } + + const products = await productsQuery.skip(skip).take(limit).getMany(); + if (products.length < 1) { + return responseSuccess( + res, + 200, + `No products found for the specified ${condition.vendor ? 'vendor' : 'category'}` + ); + } + const sanitizedProducts = products.map(product => ({ + ...product, + vendor: { + firstName: product.vendor.firstName, + lastName: product.vendor.lastName, + phoneNumber: product.vendor.phoneNumber, + photoUrl: product.vendor.photoUrl, + }, + })); + return responseSuccess(res, 200, 'Products retrieved', { products: sanitizedProducts }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/listAllProductsService.ts b/src/services/productServices/listAllProductsService.ts new file mode 100644 index 0000000..e9fa0ee --- /dev/null +++ b/src/services/productServices/listAllProductsService.ts @@ -0,0 +1,47 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { validate } from 'uuid'; + +export const listAllProductsService = async (req: Request, res: Response) => { + try { + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + const category = req.query.category; + + const productRepository = getRepository(Product); + const products = await productRepository.find({ + where: { + categories: { + name: category as string, + }, + }, + skip, + take: limit, + relations: ['categories', 'vendor', 'feedbacks'], + select: { + vendor: { + id: true, + firstName: true, + lastName: true, + email: true, + phoneNumber: true, + photoUrl: true, + }, + }, + }); + + if (products.length < 1) { + return responseSuccess(res, 200, 'No products found'); + } + if (products.length < 1) { + return responseSuccess(res, 200, 'No products found'); + } + + return responseSuccess(res, 200, 'Products retrieved', { products }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/productServices/payment.ts b/src/services/productServices/payment.ts new file mode 100644 index 0000000..b613296 --- /dev/null +++ b/src/services/productServices/payment.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; // Import your Cart entity +import { Order } from '../../entities/Order'; // Import your Order entity +import { getRepository, getTreeRepository } from 'typeorm'; +import dotenv from 'dotenv'; +import Stripe from 'stripe'; +dotenv.config(); +const stripeInstance = new Stripe(process.env.STRIPE_SECRET_KEY as string, { + apiVersion: "2024-04-10", +}); + +export const confirmPayment = async (req: Request, res: Response) => { + try { + const { payment_method } = req.body; + const cartId = req.params.cartId; // Get the cart ID from the params + + const cartRepository = getRepository(Cart); + const orderRepository = getTreeRepository(Order) + const cart = await cartRepository.findOne({where: {id : cartId}}); + if (!cart) { + return res.status(404).json({ error: 'Cart not found.' }); + } + const order = await orderRepository.findOne({ where: { buyer: cart.user } }); + if (!order) { + return res.status(404).json({ error: 'order not found.' }); + } + + const paymentIntent = await stripeInstance.paymentIntents.create({ + amount: cart.totalAmount, // Convert total to cents + currency: 'usd', + description: `Order #${cartId}`, + return_url: 'https://frontend-website.com/success', + confirm: true, + payment_method, + }); + + order.orderStatus = 'awaiting shipment'; + await orderRepository.save(order); + + + if (paymentIntent.status === 'succeeded') { + // Payment succeeded + res.status(200).json({ message: 'Payment successful!' }); + } else { + // Payment failed + res.status(400).json({ error: 'Payment failed.' }); + } + } catch (error) { + console.error('Error confirming payment:', error); + res.status(500).json({ error: 'Something went wrong' }); + } +}; \ No newline at end of file diff --git a/src/services/productServices/productStatus.ts b/src/services/productServices/productStatus.ts new file mode 100644 index 0000000..16509c3 --- /dev/null +++ b/src/services/productServices/productStatus.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; + +export const productStatusServices = async (req: Request, res: Response) => { + try { + const { isAvailable } = req.body; + const availability = isAvailable; + const { id } = req.params; + + if (availability === undefined) { + console.log('Error: Please fill all the required fields'); + return responseError(res, 401, 'Please fill all t he required fields'); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id: req.user?.id } }); + + if (!user) { + responseError(res, 404, 'User not found'); + return; + } + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id: id } }); + + if (!product) return responseError(res, 404, 'Product not found'); + + const hasProduct = await productRepository.findOne({ + where: { + id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['vendor'], + }); + + if (!hasProduct) { + return responseError(res, 404, 'Product not found in your stock'); + } + + if (hasProduct.expirationDate && hasProduct.expirationDate < new Date()) { + hasProduct.isAvailable = false; + await productRepository.save(hasProduct); + return responseSuccess(res, 201, 'Product status is set to false because it is expired.'); + } else if (hasProduct.quantity < 1) { + product.isAvailable = false; + await productRepository.save(hasProduct); + return responseSuccess(res, 202, 'Product status is set to false because it is out of stock.'); + } + + if (hasProduct.isAvailable === isAvailable) { + console.log('Error: Product status is already updated'); + responseError(res, 400, 'Product status is already up to date'); + return; + } + + hasProduct.isAvailable = isAvailable; + await productRepository.save(hasProduct); + + return responseSuccess(res, 200, 'Product status updated successfully'); + } catch (error) { + console.log('Error: Product status is not update due to this error:\n', error); + return responseServerError(res, 'Sorry, Something went wrong. Try again later.'); + } +}; diff --git a/src/services/productServices/readProduct.ts b/src/services/productServices/readProduct.ts new file mode 100644 index 0000000..77896ce --- /dev/null +++ b/src/services/productServices/readProduct.ts @@ -0,0 +1,78 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const readProductsService = async (req: Request, res: Response) => { + try { + // Define pagination parameters + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + + // Retrieve products + const productRepository = getRepository(Product); + const products = await productRepository.find({ + where: { + vendor: { + id: req.user?.id, + }, + }, + skip, + take: limit, + relations: ['categories', 'vendor', 'feedbacks'], + select: { + vendor: { + id: true, + firstName: true, + lastName: true, + email: true, + phoneNumber: true, + photoUrl: true, + }, + }, + }); + + if (products.length < 1) { + return responseSuccess(res, 200, 'You have no products yet'); + } + return responseSuccess(res, 200, 'Products retrieved', { products }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; + +export const readProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['categories', 'vendor'], + select: { + vendor: { + id: true, + firstName: true, + lastName: true, + email: true, + phoneNumber: true, + photoUrl: true, + }, + }, + }); + + if (!product) { + return responseError(res, 404, 'Product not found'); + } + + return responseSuccess(res, 200, 'Product retrieved', { product }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/productServices/removeProductImage.ts b/src/services/productServices/removeProductImage.ts new file mode 100644 index 0000000..4424676 --- /dev/null +++ b/src/services/productServices/removeProductImage.ts @@ -0,0 +1,54 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const removeProductImageService = async (req: Request, res: Response) => { + const { image } = req.body; + + if (!image) { + return res.status(400).json({ status: 'error', error: 'Please provide an image to remove' }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + vendor: { id: req.user?.id }, + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + const index = product.images.indexOf(image); + + if (index === -1) { + return res.status(404).json({ status: 'error', error: 'Image not found' }); + } + + if (product.images.length === 2) { + return res.status(400).json({ status: 'error', error: 'Product must have at least two image' }); + } + + product.images.splice(index, 1); + await productRepository.save(product); + product.vendor = product.vendor.id as unknown as User; + + return res.status(200).json({ + status: 'success', + data: { + message: 'Image removed successfully', + product, + }, + }); +}; diff --git a/src/services/productServices/searchProduct.ts b/src/services/productServices/searchProduct.ts new file mode 100644 index 0000000..9f33b5f --- /dev/null +++ b/src/services/productServices/searchProduct.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { getRepository, Like } from 'typeorm'; +import { Product } from '../../entities/Product'; + +interface SearchProductParams { + name?: string; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + page?: number; + limit?: number; +} + +export const searchProductService = async (params: SearchProductParams) => { + const { name, sortBy, sortOrder, page = 1, limit = 10 } = params; + + const productRepository = getRepository(Product); + let query = productRepository.createQueryBuilder('product'); + + if (name) { + query = query.where('product.name LIKE :name', { name: `%${name}%` }); + } + + if (sortBy && sortOrder) { + query = query.orderBy(`product.${sortBy}`, sortOrder as 'ASC' | 'DESC'); + } + + const skip = (page - 1) * limit; + + const [products, total] = await query.skip(skip).take(limit).getManyAndCount(); + + const totalPages = Math.ceil(total / limit); + + return { + data: products, + pagination: { + totalItems: total, + currentPage: page, + totalPages, + itemsPerPage: limit, + }, + }; +}; diff --git a/src/services/productServices/updateProduct.ts b/src/services/productServices/updateProduct.ts new file mode 100644 index 0000000..409dd48 --- /dev/null +++ b/src/services/productServices/updateProduct.ts @@ -0,0 +1,117 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; +import { Category } from '../../entities/Category'; +import { responseError } from '../../utils/response.utils'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const updateProductService = async (req: Request, res: Response) => { + try { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + + if (parseInt(req.body.quantity) === 0) { + product.isAvailable = false; + product.quantity = req.body.quantity; + } else { + product.isAvailable = true; + product.quantity = req.body.quantity; + } + + if (req.files) { + if (product.images.length + req.files.length > 6) { + return res.status(400).json({ status: 'error', error: 'Product cannot have more than 6 images' }); + } + + const imageUrls: string[] = []; + for (const image of req.files) { + const link = await cloudinary.uploader.upload(image.path); + imageUrls.push(link.secure_url); + } + product.images = [...product.images, ...imageUrls]; + } + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + + if (req.body.oldPrice) { + product.oldPrice = req.body.oldPrice; + } + + const categoryRepository = getRepository(Category); + let categories = []; + if (typeof req.body.categories === 'string') { + let category = await categoryRepository.findOne({ + where: { name: req.body.categories.toLowerCase() }, + }); + if (!category) { + category = new Category(); + category.name = req.body.categories.toLowerCase(); + category = await categoryRepository.save(category); + } + categories.push(category); + } else { + categories = await Promise.all( + req.body.categories.map(async (categoryName: string) => { + let category = await categoryRepository.findOne({ where: { name: categoryName.toLowerCase() } }); + if (!category) { + category = new Category(); + category.name = categoryName.toLowerCase(); + await categoryRepository.save(category); + } + return category; + }) + ); + } + + product.categories = categories; + + await productRepository.save(product); + + product.vendor = { + id: product.vendor.id, + name: product.vendor.firstName + ' ' + product.vendor.lastName, + } as unknown as User; + + return res.status(200).json({ + status: 'success', + data: { + message: 'Product updated successfully', + product, + }, + }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/viewSingleProduct.ts b/src/services/productServices/viewSingleProduct.ts new file mode 100644 index 0000000..6f49532 --- /dev/null +++ b/src/services/productServices/viewSingleProduct.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError } from '../../utils/response.utils'; +import { validate } from 'uuid'; + +export const viewSingleProduct = async (req: Request, res: Response) => { + try { + const productId = req.params.id; + + if (!validate(productId)) { + return res.status(400).json({ status: 'error', message: 'Invalid product ID' }); + } + if (productId) { + const products = getRepository(Product); + const product = await products.findOne({ where: { id: productId }, relations: ['categories', 'vendor', 'feedbacks'], }); + + if (!product) { + return res.status(404).send({ status: 'error', message: 'Product not found' }); + } + + if (product.expirationDate && new Date(product.expirationDate) < new Date()) { + return res.status(400).json({ status: 'error', message: 'Product expired' }); + } + res.status(200).json({ status: 'success', product: product }); + } + } catch (error) { + console.error('Error handling request:', error); + res.status(500).send('Error fetching product details'); + } +}; \ No newline at end of file diff --git a/src/services/updateUserStatus/activateUserService.ts b/src/services/updateUserStatus/activateUserService.ts index c499280..8ec1a8a 100644 --- a/src/services/updateUserStatus/activateUserService.ts +++ b/src/services/updateUserStatus/activateUserService.ts @@ -4,36 +4,36 @@ import { getRepository } from 'typeorm'; import { sendEmail } from '../../utils/sendStatusMail'; enum UserStatus { - ACTIVE = 'active', - INACTIVE = 'suspended', + ACTIVE = 'active', + INACTIVE = 'suspended', } -export const activateUserService = async (req:Request,res:Response)=>{ - try { - const {email} = req.body; - const userRepository = getRepository(User); +export const activateUserService = async (req: Request, res: Response) => { + try { + const { email } = req.body; + const userRepository = getRepository(User); - if(!email){ - return res.status(404).json({error: 'Email is needed'}); - } + if (!email) { + return res.status(404).json({ error: 'Email is needed' }); + } - const user = await userRepository.findOneBy({ email }); + const user = await userRepository.findOneBy({ email }); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } - if (user.status === 'active') { - return res.json({ message: 'User is already active' }); - } + if (user.status === 'active') { + return res.json({ message: 'User is already active' }); + } - user.status = UserStatus.ACTIVE; - await userRepository.save(user); + user.status = UserStatus.ACTIVE; + await userRepository.save(user); - await sendEmail('User_Account_activated', { name: user.firstName, email: user.email }); + await sendEmail('User_Account_activated', { name: user.firstName, email: user.email }); - return res.status(200).json({ message: 'User activated successfully', user }); - } catch (error) { - return res.status(500).json({ error: 'Internal server error' }); - } -} \ No newline at end of file + return res.status(200).json({ message: 'User activated successfully', user }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/updateUserStatus/deactivateUserService.ts b/src/services/updateUserStatus/deactivateUserService.ts index c72c394..597c8f2 100644 --- a/src/services/updateUserStatus/deactivateUserService.ts +++ b/src/services/updateUserStatus/deactivateUserService.ts @@ -4,36 +4,36 @@ import { getRepository } from 'typeorm'; import { sendEmail } from '../../utils/sendStatusMail'; enum UserStatus { - ACTIVE = 'active', - INACTIVE = 'suspended', + ACTIVE = 'active', + INACTIVE = 'suspended', } -export const deactivateUserService = async (req:Request,res:Response)=>{ - try { - const {email} = req.body; - const userRepository = getRepository(User); +export const deactivateUserService = async (req: Request, res: Response) => { + try { + const { email } = req.body; + const userRepository = getRepository(User); - if(!email){ - return res.status(404).json({error: 'Email is needed'}); - } + if (!email) { + return res.status(404).json({ error: 'Email is needed' }); + } - const user = await userRepository.findOneBy({ email }); + const user = await userRepository.findOneBy({ email }); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } - if (user.status === 'suspended') { - return res.json({ message: 'User is already suspended' }); - } + if (user.status === 'suspended') { + return res.json({ message: 'User is already suspended' }); + } - user.status = UserStatus.INACTIVE; - await userRepository.save(user); + user.status = UserStatus.INACTIVE; + await userRepository.save(user); - await sendEmail('User_Account_diactivated', { name: user.firstName, email: user.email }); + await sendEmail('User_Account_diactivated', { name: user.firstName, email: user.email }); - return res.json({ message: 'User deactivated successfully', user }); - } catch (error) { - return res.status(500).json({ error: 'Internal server error' }); - } -} \ No newline at end of file + return res.json({ message: 'User deactivated successfully', user }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/userServices/sendResetPasswordLinkService.ts b/src/services/userServices/sendResetPasswordLinkService.ts index b94bb97..f9b7dbf 100644 --- a/src/services/userServices/sendResetPasswordLinkService.ts +++ b/src/services/userServices/sendResetPasswordLinkService.ts @@ -1,34 +1,34 @@ -import { Request, Response } from "express"; -import { responseError, responseServerError, responseSuccess } from "../../utils/response.utils"; +import { Request, Response } from 'express'; +import { responseError, responseServerError, responseSuccess } from '../../utils/response.utils'; import nodemailer from 'nodemailer'; -import { getRepository } from "typeorm"; -import { User } from "../../entities/User"; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; export const sendPasswordResetLinkService = async (req: Request, res: Response) => { - try { - const transporter = nodemailer.createTransport({ - host: process.env.HOST, - port: 587, - secure: false, // true for 465, false for other ports - auth: { - user: process.env.AUTH_EMAIL, - pass: process.env.AUTH_PASSWORD - }, - }); - const email = req.query.email as string; + try { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL, + pass: process.env.AUTH_PASSWORD, + }, + }); + const email = req.query.email as string; - if (!email) { - return responseError(res, 404, 'Missing required field'); - } - const userRepository = getRepository(User); - const existingUser = await userRepository.findOneBy({ email }); - if (!existingUser) { - return responseError(res, 404, 'User not found', existingUser); - } - const mailOptions: nodemailer.SendMailOptions = { - to: email, - subject: `Password reset link `, - html: ` + if (!email) { + return responseError(res, 404, 'Missing required field'); + } + const userRepository = getRepository(User); + const existingUser = await userRepository.findOneBy({ email }); + if (!existingUser) { + return responseError(res, 404, 'User not found', existingUser); + } + const mailOptions: nodemailer.SendMailOptions = { + to: email, + subject: `Password reset link `, + html: ` @@ -102,19 +102,16 @@ export const sendPasswordResetLinkService = async (req: Request, res: Response) - ` - }; - - try { - const sendMail = await transporter.sendMail(mailOptions); - return responseSuccess(res, 200, "Code sent on your email", sendMail); - } catch (error) {; - return responseError(res, 500, 'Error occurred while sending email'); - } - + `, + }; + try { + const sendMail = await transporter.sendMail(mailOptions); + return responseSuccess(res, 200, 'Code sent on your email', sendMail); } catch (error) { - return responseServerError(res, `Internal server error: `); + return responseError(res, 500, 'Error occurred while sending email'); } -} - ; \ No newline at end of file + } catch (error) { + return responseServerError(res, `Internal server error: `); + } +}; diff --git a/src/services/userServices/userProfileUpdateServices.ts b/src/services/userServices/userProfileUpdateServices.ts new file mode 100644 index 0000000..c140e38 --- /dev/null +++ b/src/services/userServices/userProfileUpdateServices.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { User, UserInterface } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { userProfileUpdate } from '../../controllers/authController'; + +declare module 'express' { + interface Request { + user?: Partial; + } +} + +export const userProfileUpdateServices = async (req: Request, res: Response) => { + try { + if (!req.body) { + return responseError(res, 401, 'body required'); + } + + const { firstName, lastName, gender, phoneNumber, photoUrl, email, id } = req.body; + + // Validate user input + if ( + !firstName.trim() && + !lastName.trim() && + !gender.trim() && + !phoneNumber.trim() && + !photoUrl.trim() && + !email.trim() && + !id.trim() + ) { + return responseError(res, 400, 'Fill all the field'); + } + + const userRepository = getRepository(User); + const existingUser = await userRepository.findOne({ + where: { email: req.body.email }, + }); + + if (!existingUser) { + return responseError(res, 401, 'User not found'); + } + if (existingUser.id !== id) { + return responseError(res, 403, 'You are not authorized to edit this profile.'); + } + + existingUser.firstName = firstName; + existingUser.lastName = lastName; + existingUser.gender = gender; + existingUser.phoneNumber = phoneNumber; + existingUser.photoUrl = photoUrl; + + await userRepository.save(existingUser); + return responseSuccess(res, 201, 'User Profile has successfully been updated'); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/vendorOrderServices/readVendorOrder.ts b/src/services/vendorOrderServices/readVendorOrder.ts new file mode 100644 index 0000000..feec0c3 --- /dev/null +++ b/src/services/vendorOrderServices/readVendorOrder.ts @@ -0,0 +1,119 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { VendorOrderItem } from '../../entities/VendorOrderItem'; +import { VendorOrders } from '../../entities/vendorOrders'; + +export const getVendorOrdersService = async (req: Request, res: Response) => { + try { + const vendorOrderRepository = getRepository(VendorOrders); + const vendorId = req.user?.id; + + const vendorOrders = await vendorOrderRepository.find({ + where: { + vendor: { + id: vendorId, + }, + }, + relations: ['vendor', 'order.buyer', 'vendorOrderItems', 'vendorOrderItems.product'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + if (!vendorOrders.length) { + return responseError(res, 200, `You don't have any pending orders from buyer`, { orders: [] }); + } + + const sanitizedOrderResponse = vendorOrders.map(order => ({ + id: order.id, + totalPrice: order.totalPrice, + orderStatus: order.orderStatus, + address: order.order.address, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + vendor: { + id: order.vendor.id, + firstName: order.vendor.firstName, + lastName: order.vendor.lastName, + email: order.vendor.email, + gender: order.vendor.gender, + phoneNumber: order.vendor.phoneNumber, + photoUrl: order.vendor.photoUrl, + }, + buyer: { + id: order.order.buyer.id, + firstName: order.order.buyer.firstName, + lastName: order.order.buyer.lastName, + email: order.order.buyer.lastName, + gender: order.order.buyer.gender, + phoneNumber: order.order.buyer.phoneNumber, + photoUrl: order.order.buyer.photoUrl, + }, + vendorOrderItems: order.vendorOrderItems, + })); + responseSuccess(res, 200, 'Orders retrieved successfully', { + orders: sanitizedOrderResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; + +// Get single vendor order info +export const getSingleVendorOrderService = async (req: Request, res: Response) => { + try { + const vendorOrderId = req.params.id; + + const vendorOrderRepository = getRepository(VendorOrders); + const vendorId = req.user?.id; + + const vendorOrder = await vendorOrderRepository.findOne({ + where: { + id: vendorOrderId, + vendor: { + id: vendorId, + }, + }, + relations: ['vendor', 'order.buyer', 'vendorOrderItems', 'vendorOrderItems.product'], + }); + + if (!vendorOrder) { + return responseError(res, 404, `Order Not Found.`); + } + + const sanitizedOrderResponse = { + id: vendorOrder.id, + totalPrice: vendorOrder.totalPrice, + orderStatus: vendorOrder.orderStatus, + address: vendorOrder.order.address, + createdAt: vendorOrder.createdAt, + updatedAt: vendorOrder.updatedAt, + vendor: { + id: vendorOrder.vendor.id, + firstName: vendorOrder.vendor.firstName, + lastName: vendorOrder.vendor.lastName, + email: vendorOrder.vendor.email, + gender: vendorOrder.vendor.gender, + phoneNumber: vendorOrder.vendor.phoneNumber, + photoUrl: vendorOrder.vendor.photoUrl, + }, + buyer: { + id: vendorOrder.order.buyer.id, + firstName: vendorOrder.order.buyer.firstName, + lastName: vendorOrder.order.buyer.lastName, + email: vendorOrder.order.buyer.lastName, + gender: vendorOrder.order.buyer.gender, + phoneNumber: vendorOrder.order.buyer.phoneNumber, + photoUrl: vendorOrder.order.buyer.photoUrl, + }, + vendorOrderItems: vendorOrder.vendorOrderItems, + }; + + responseSuccess(res, 200, 'Order retrieved successfully', { + order: sanitizedOrderResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/vendorOrderServices/updateVendorOrder.ts b/src/services/vendorOrderServices/updateVendorOrder.ts new file mode 100644 index 0000000..cae2c60 --- /dev/null +++ b/src/services/vendorOrderServices/updateVendorOrder.ts @@ -0,0 +1,87 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { OrderItem } from '../../entities/OrderItem'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import sendMail from '../../utils/sendOrderMail'; +import { VendorOrders } from '../../entities/vendorOrders'; +import { getIO } from '../../utils/socket'; + +export const updateVendorOrderService = async (req: Request, res: Response) => { + try { + const vendorOrderId = req.params.id; + const { orderStatus } = req.body; + if ( + !['pending', 'is-accepted', 'in-transit', 'cancelled', 'delivered'].includes( + (orderStatus as string).toLowerCase() + ) + ) { + return responseError(res, 400, `Please provide one of defined statuses.`); + } + + const vendorOrderRepository = getRepository(VendorOrders); + const vendorId = req.user?.id; + + const vendorOrder = await vendorOrderRepository.findOne({ + where: { + id: vendorOrderId, + vendor: { + id: vendorId, + }, + }, + relations: ['vendor', 'order.buyer', 'vendorOrderItems', 'vendorOrderItems.product'], + }); + + if (!vendorOrder) { + return responseError(res, 404, `Order Not Found.`); + } + + // Check if order can be updated + if (['delivered', 'cancelled', 'completed'].includes(vendorOrder.orderStatus)) { + return responseError(res, 409, `Order cannot be updated once it is marked as ${vendorOrder.orderStatus}`); + } + + vendorOrder.orderStatus = (orderStatus as string).toLowerCase(); + + // Save updated order status + const updatedVendorOrder = await vendorOrderRepository.save(vendorOrder); + + const sanitizedOrderResponse = { + id: updatedVendorOrder.id, + totalPrice: updatedVendorOrder.totalPrice, + orderStatus: updatedVendorOrder.orderStatus, + address: updatedVendorOrder.order.address, + createdAt: updatedVendorOrder.createdAt, + updatedAt: updatedVendorOrder.updatedAt, + vendor: { + id: updatedVendorOrder.vendor.id, + firstName: updatedVendorOrder.vendor.firstName, + lastName: updatedVendorOrder.vendor.lastName, + email: updatedVendorOrder.vendor.email, + gender: updatedVendorOrder.vendor.gender, + phoneNumber: updatedVendorOrder.vendor.phoneNumber, + photoUrl: updatedVendorOrder.vendor.photoUrl, + }, + buyer: { + id: updatedVendorOrder.order.buyer.id, + firstName: updatedVendorOrder.order.buyer.firstName, + lastName: updatedVendorOrder.order.buyer.lastName, + email: updatedVendorOrder.order.buyer.lastName, + gender: updatedVendorOrder.order.buyer.gender, + phoneNumber: updatedVendorOrder.order.buyer.phoneNumber, + photoUrl: updatedVendorOrder.order.buyer.photoUrl, + }, + vendorOrderItems: updatedVendorOrder.vendorOrderItems, + }; + + getIO().emit('orders', { + action: 'vendor update', + order: sanitizedOrderResponse, + }); + + return responseSuccess(res, 200, 'Order updated successfully', { + order: sanitizedOrderResponse, + }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/wishListServices/addProduct.ts b/src/services/wishListServices/addProduct.ts new file mode 100644 index 0000000..79d0a38 --- /dev/null +++ b/src/services/wishListServices/addProduct.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; +import { Product } from '../../entities/Product'; + +export const addProductService = async (req: Request, res: Response) => { + try { + const id = req.params.id; + const wishListRepository = getRepository(wishList); + const productRepository = getRepository(Product); + + const product = await productRepository.findOne({ where: { id } }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + const productDetails = { + productId: product.id, + name: product.name, + image: product.images, + newPrice: product.newPrice, + vendorId: product.vendor, + }; + + const alreadyIn = await wishListRepository.findOne({ where: { productId: id, buyer: { id: req.user?.id } } }); + + if (alreadyIn) { + return res.status(401).json({ + data: { + message: 'Product Already in the wish list', + wishlistAdded: alreadyIn, + product: productDetails, + }, + }); + } + + const addNewProduct = new wishList(); + addNewProduct.productId = id; + addNewProduct.buyer = req.user as User; + + await wishListRepository.save(addNewProduct); + + addNewProduct.buyer = { id: addNewProduct.buyer.id } as unknown as User; + + return res.status(201).json({ + data: { + message: 'Product Added to wish list', + wishlistAdded: addNewProduct, + product: productDetails, + }, + }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/wishListServices/clearAll.ts b/src/services/wishListServices/clearAll.ts new file mode 100644 index 0000000..7299454 --- /dev/null +++ b/src/services/wishListServices/clearAll.ts @@ -0,0 +1,19 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; + +export const clearAllProductService = async (req: Request, res: Response) => { + try { + const wishListRepository = getRepository(wishList); + const productsForBuyer = await wishListRepository.find({ where: { buyer: { id: req.user?.id } } }); + + if (productsForBuyer.length === 0) { + return res.status(404).json({ message: 'No products in wish list' }); + } + + await wishListRepository.remove(productsForBuyer); + return res.status(200).json({ message: 'All products removed successfully' }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/wishListServices/getProducts.ts b/src/services/wishListServices/getProducts.ts new file mode 100644 index 0000000..98dc434 --- /dev/null +++ b/src/services/wishListServices/getProducts.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; +import { Product } from '../../entities/Product'; + +export const getProductsService = async (req: Request, res: Response) => { + try { + const wishListRepository = getRepository(wishList); + const productRepository = getRepository(Product); + + const productsForBuyer = await wishListRepository.find({ where: { buyer: { id: req.user?.id } } }); + + if (productsForBuyer.length === 0) { + return res.status(404).json({ message: 'No products in wish list', products: productsForBuyer }); + } + + const buyerWishProducts = await Promise.all( + productsForBuyer.map(async product => { + const productDetails = await productRepository.findOne({ where: { id: product.productId } }); + if (productDetails) { + return { + wishListDetails: product, + productInfo: { + productId: productDetails.id, + name: productDetails.name, + image: productDetails.images, + newPrice: productDetails.newPrice, + vendorId: productDetails.vendor, + }, + }; + } + }) + ); + + return res.status(200).json({ message: 'Products retrieved', productsForBuyer: buyerWishProducts }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/wishListServices/removeProducts.ts b/src/services/wishListServices/removeProducts.ts new file mode 100644 index 0000000..b42052f --- /dev/null +++ b/src/services/wishListServices/removeProducts.ts @@ -0,0 +1,21 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; + +export const removeProductService = async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id); + const wishListRepository = getRepository(wishList); + + const product = await wishListRepository.findOne({ where: { id } }); + + if (!product) { + return res.status(404).json({ message: 'Product not found in wish list' }); + } + + await wishListRepository.remove(product); + return res.status(200).json({ message: 'Product removed from wish list' }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/startups/getSwaggerServer.ts b/src/startups/getSwaggerServer.ts index 87a1f51..6a7416d 100644 --- a/src/startups/getSwaggerServer.ts +++ b/src/startups/getSwaggerServer.ts @@ -7,7 +7,7 @@ function getSwaggerServer (): string { return process.env.SWAGGER_SERVER; } - return 'http://localhost:7000/api/v1'; + return `http://localhost:${process.env.PORT}`; } export { getSwaggerServer }; diff --git a/src/train.ts b/src/train.ts new file mode 100644 index 0000000..732324e --- /dev/null +++ b/src/train.ts @@ -0,0 +1,43 @@ + +import fs from 'fs' + +import { NlpManager } from 'node-nlp'; + +export const manager = new NlpManager({ languages: ["en"] }); + +async function trainManager() { + const intentFiles = fs.readdirSync('./intents'); + + for (const file of intentFiles) { + try { + const filePath = `./intents/${file}`; + const data = await fs.promises.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(data); + + const intent = file.replace('.json', ''); + + for (const utterances of jsonData.utterances) { + manager.addDocument('en', utterances, intent); + } + + for (const responses of jsonData.responses) { + manager.addAnswer('en', intent, responses); + } + } catch (error) { + console.error(`Error processing intent file ${file}:`, error); + } + } + + await manager.train(); +} + +trainManager() + .then(async () => { + manager.save(); + }) + .catch((error) => console.error('Error training NLP manager:', error)); + + module.exports = { + manager, + trainManager + }; diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..623883f --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,66 @@ +/* eslint-disable camelcase */ +import passport from 'passport'; +import { Strategy } from 'passport-google-oauth20'; +import { User } from '../entities/User'; +import { getRepository } from 'typeorm'; +import bcrypt from 'bcrypt'; +import '../utils/auth'; +passport.use( + new Strategy( + { + clientID: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + callbackURL: 'http://localhost:6890/user/auth/google/callback/', + scope: ['email', 'profile'], + }, + async (accessToken: any, refreshToken: any, profile: any, cb: any) => { + const userRepository = getRepository(User); + const { family_name, name, picture, email, email_verified } = profile._json; + const { familyName, givenName } = profile.name; + + if (email || givenName || family_name || picture) { + try { + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + + if (existingUser) { + return await cb(null, existingUser); + } + const saltRounds = 10; + const hashedPassword = await bcrypt.hash('password', saltRounds); + const newUser = new User(); + newUser.firstName = givenName; + newUser.lastName = family_name ?? familyName ?? 'undefined'; + newUser.email = email; + newUser.userType = 'Buyer'; + newUser.photoUrl = picture; + newUser.gender = 'Not specified'; + newUser.phoneNumber = 'Not specified'; + newUser.password = hashedPassword; + newUser.verified = email_verified; + + await userRepository.save(newUser); + return await cb(null, newUser); + } catch (error) { + console.error(error); + return await cb(error, null); + } + } + return await cb(null, profile, { message: 'Missing required profile information' }); + } + ) +); + +passport.serializeUser((user: any, cb) => { + cb(null, user.id); +}); + +passport.deserializeUser(async (id: any, cb) => { + const userRepository = getRepository(User); + try { + const user = await userRepository.findOneBy({ id }); + cb(null, user); + } catch (error) { + cb(error); + } +}); diff --git a/src/utils/cloudinary.ts b/src/utils/cloudinary.ts new file mode 100644 index 0000000..18b8db3 --- /dev/null +++ b/src/utils/cloudinary.ts @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +import { v2 as cloudinary } from 'cloudinary'; +import dotenv from 'dotenv'; + +dotenv.config(); + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDNARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export default cloudinary; diff --git a/src/utils/getNotifications.ts b/src/utils/getNotifications.ts new file mode 100644 index 0000000..5cc6309 --- /dev/null +++ b/src/utils/getNotifications.ts @@ -0,0 +1,27 @@ +import { getRepository } from "typeorm"; +import { Notification } from "../entities/Notification"; + +export const getNotifications = async (userId: string) => { + try { + const notificationRepository = getRepository(Notification); + + const notifications = await notificationRepository.findOne({ + where: { + user: { + id: userId + } + }, + relations: { + allNotifications: true + } + }); + + if (!notifications) { + return {}; + } + return notifications; + } catch (error) { + console.error((error as Error).message); + return {}; + } +}; \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 490375a..fdc7feb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,20 @@ // export all utils +/** + * Format a number as a currency string. + * @param amount - The amount to format. + * @param currency - The currency code (e.g., 'USD', 'EUR'). Defaults to 'USD'. + * @returns The formatted currency string. + */ +export function formatMoney (amount: number, currency: string = 'RWF'): string { + return amount.toLocaleString('en-US', { style: 'currency', currency }); +} +/** + * Format a date string into a more readable format. + * @param dateString - The date string to format. + * @returns The formatted date string. + */ +export function formatDate (dateString: Date): string { + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', options); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e537ca6..d9c996a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,5 +1,4 @@ import { createLogger, format, transports } from 'winston'; -// import winston, { createLogger, transports, format } from 'winston'; // Define custom logging levels and colors const logLevels = { diff --git a/src/utils/response.utils.ts b/src/utils/response.utils.ts index 3be109b..f0e5513 100644 --- a/src/utils/response.utils.ts +++ b/src/utils/response.utils.ts @@ -17,7 +17,7 @@ export const responseSuccess = ( jsend.success({ code: statusCode, message, - data, + ...data, }) ); }; @@ -45,3 +45,11 @@ export const responseServerError = (res: Response, error: string): Response { + return res.status(statusCode).json({ status: 'success', message, data }); +}; + +export const sendErrorResponse = (res: Response, statusCode: number, message: string) => { + return res.status(statusCode).json({ status: 'error', message }); +}; diff --git a/src/utils/sendNotification.ts b/src/utils/sendNotification.ts new file mode 100644 index 0000000..de46d32 --- /dev/null +++ b/src/utils/sendNotification.ts @@ -0,0 +1,58 @@ +import { Notification } from "../entities/Notification"; +import { NotificationItem } from "../entities/NotificationItem"; +import { getRepository } from 'typeorm'; +import { User } from "../entities/User"; +import { getIO } from "./socket"; +import { getNotifications } from "./getNotifications"; + +interface noticationInfo{ + content: string; + type: 'product'|'cart'|'order'|'user'|'wish list'|'coupon'; + user: User; + link?: string; +} + +export const sendNotification = async (data: noticationInfo) =>{ + try { + const notificationRepo = getRepository(Notification) + const notificationItemRepo = getRepository(NotificationItem); + + let notification = await notificationRepo + .findOne({ + where: { + user: {id: data.user.id}}, + relations: ['allNotifications', 'user'] + }); + + if(!notification){ + notification = new Notification(); + notification.user = data.user; + await notificationRepo.save(notification); + } + + const notificationItem = new NotificationItem(); + notificationItem.notification = notification; + notificationItem.content = data.content; + notificationItem.type = data.type; + if(data.link){ + notificationItem.link = data.link + } + await notificationItemRepo.save(notificationItem); + + //Update numbers + notification = await notificationRepo + .findOne({where: {id: notification.id, user: {id: data.user.id}}, relations: ['allNotifications', 'user'] }); + + if(notification){ + notification.updateUnread(); + await notificationRepo.save(notification); + } + + getIO().emit('notification', { + action: `${data.user.email} notification`, + notifications: await getNotifications(data.user.id) + }); + } catch (error) { + console.log(error); + } +}; \ No newline at end of file diff --git a/src/utils/sendOrderMail.ts b/src/utils/sendOrderMail.ts new file mode 100644 index 0000000..72ee5b0 --- /dev/null +++ b/src/utils/sendOrderMail.ts @@ -0,0 +1,215 @@ +import nodemailer from 'nodemailer'; +import { formatMoney, formatDate } from './index'; + +interface Product { + name: string; + newPrice: number; + quantity: number; +} + +interface Message { + subject: string; + fullName: string; + email: string; + products: Product[]; + totalAmount: number; + quantity: number; + orderDate: Date; + address: string; +} + +const sendMail = async (message: Message) => { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL as string, + pass: process.env.AUTH_PASSWORD as string, + }, + }); + + const { subject, fullName, email, products, totalAmount, quantity, orderDate, address } = message; + + const mailOptions = { + to: email, + subject: subject, + html: ` + + + + + + + Order Details + + + + +
+ shoping image +

Order Success

+
+

User information

+ + + + + + + + + + + + + + + +
Full Name:Email:Address:
${fullName}${email}${address}
+ + + + + + + + + + + + + + + +
Order Date:Quantity:Total Amount:
${formatDate(orderDate)}${quantity}${formatMoney(totalAmount)}
+
+
+

Products

+ + + + + + + + ${products + .map( + (product: Product) => ` + + + + + + + ` + ) + .join('')} + + + + +
Product NameProduct PriceQuantityTotal
${product.name}${formatMoney(product.newPrice)}${product.quantity}${product.quantity * product.newPrice}
Total${totalAmount}
+
+ +
+ + + `, + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Email sent: ' + info.response); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; + +export default sendMail; diff --git a/src/utils/sendOrderMailUpdated.ts b/src/utils/sendOrderMailUpdated.ts new file mode 100644 index 0000000..adddc9a --- /dev/null +++ b/src/utils/sendOrderMailUpdated.ts @@ -0,0 +1,215 @@ +import nodemailer from 'nodemailer'; +import { formatMoney, formatDate } from './index'; + +interface Product { + name: string; + newPrice: number; + quantity: number; +} + +interface Message { + subject: string; + fullName: string; + email: string; + products: Product[]; + totalAmount: number; + quantity: number; + orderDate: Date; + address: string; +} + +const sendMail = async (message: Message) => { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL as string, + pass: process.env.AUTH_PASSWORD as string, + }, + }); + + const { subject, fullName, email, products, totalAmount, quantity, orderDate, address } = message; + + const mailOptions = { + to: email, + subject: subject, + html: ` + + + + + + + Your order details have been updated + + + + +
+ shoping image +

Order Updated

+
+

User information

+ + + + + + + + + + + + + + + +
Full Name:Email:Address:
${fullName}${email}${address}
+ + + + + + + + + + + + + + + +
Order Date:Quantity:Total Amount:
${formatDate(orderDate)}${quantity}${formatMoney(totalAmount)}
+
+
+

Products

+ + + + + + + + ${products + .map( + (product: Product) => ` + + + + + + + ` + ) + .join('')} + + + + +
Product NameProduct PriceQuantityTotal
${product.name}${formatMoney(product.newPrice)}${product.quantity}${product.quantity * product.newPrice}
Total${totalAmount}
+
+ +
+ + + `, + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Email sent: ' + info.response); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; + +export default sendMail; diff --git a/src/utils/sendStatusMail.ts b/src/utils/sendStatusMail.ts index 877b2a0..0f363ac 100644 --- a/src/utils/sendStatusMail.ts +++ b/src/utils/sendStatusMail.ts @@ -13,7 +13,7 @@ const EMAIL = process.env.AUTH_EMAIL; const PASSWORD = process.env.AUTH_PASSWORD; export const sendEmail = async (type: string, data: IData) => { - if(EMAIL && PASSWORD){ + if (EMAIL && PASSWORD) { try { const mailGenerator = new Mailgen({ theme: 'default', @@ -22,7 +22,7 @@ export const sendEmail = async (type: string, data: IData) => { link: 'https://mailgen.js/', }, }); - + const transporter = nodemailer.createTransport({ service: 'gmail', auth: { @@ -30,7 +30,7 @@ export const sendEmail = async (type: string, data: IData) => { pass: PASSWORD, }, }); - + let email; let subject; switch (type) { @@ -73,23 +73,22 @@ export const sendEmail = async (type: string, data: IData) => { default: throw new Error('Invalid email type'); } - + const html = mailGenerator.generate(email); - + const mailOptions = { from: EMAIL, to: data.email, subject: subject, html: html, }; - + const info = await transporter.sendMail(mailOptions); } catch (error) { console.error('Error sending email:', error); } - } - else { + } else { console.error('Email or password for mail server not configured'); - return -} + return; + } }; diff --git a/src/utils/socket.ts b/src/utils/socket.ts new file mode 100644 index 0000000..e32ae19 --- /dev/null +++ b/src/utils/socket.ts @@ -0,0 +1,21 @@ +import { Server as HTTPServer } from 'http'; +import { Server as SocketIOServer } from 'socket.io'; + +let io: SocketIOServer | undefined; + +export const init = (httpServer: HTTPServer): SocketIOServer => { + io = new SocketIOServer(httpServer, { + cors: { + origin: '*', + methods: ['GET', 'POST', 'DELETE', 'PUT'], + }, + }); + return io; +}; + +export const getIO = (): SocketIOServer => { + if (!io) { + throw new Error('Socket.io not initialized!'); + } + return io; +}; diff --git a/tsconfig.json b/tsconfig.json index 88326b4..94cd76c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,10 +28,13 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": ["node_modules/@types", "./src"], /* Specify multiple folders that act like './node_modules/@types'. */ "types": [ "node", - "jest" + "jest", + "express", + "node-nlp", + "joi" ] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */