diff --git a/package-lock.json b/package-lock.json index d6b2f0e..0b5f764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,10 +23,12 @@ "http-errors": "~1.6.3", "jsonwebtoken": "^9.0.0", "knex": "^2.5.1", + "libphonenumber-js": "^1.12.8", "moment": "^2.29.4", "mongodb": "^6.14.0", "mongoose": "^8.11.0", "morgan": "~1.9.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "mysql2": "^3.14.1", "node-schedule": "^2.1.1", @@ -42,6 +44,7 @@ "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", "@types/nodemailer": "^6.4.17", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", @@ -311,6 +314,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.15.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", @@ -537,6 +550,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -756,7 +775,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/buffer-more-ints": { @@ -765,6 +783,17 @@ "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -962,6 +991,21 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2127,6 +2171,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, + "node_modules/libphonenumber-js": { + "version": "1.12.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.8.tgz", + "integrity": "sha512-f1KakiQJa9tdc7w1phC2ST+DyxWimy9c3g3yeF+84QtEanJr2K77wAmBPP22riU05xldniHsvXuflnLZ4oysqA==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -2351,7 +2401,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2629,6 +2678,36 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mysql": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", @@ -3586,6 +3665,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3871,6 +3958,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/package.json b/package.json index 666622a..c48674e 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ "http-errors": "~1.6.3", "jsonwebtoken": "^9.0.0", "knex": "^2.5.1", + "libphonenumber-js": "^1.12.8", "moment": "^2.29.4", "mongodb": "^6.14.0", "mongoose": "^8.11.0", "morgan": "~1.9.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "mysql2": "^3.14.1", "node-schedule": "^2.1.1", @@ -43,6 +45,7 @@ "@types/express": "^5.0.1", "@types/jsonwebtoken": "^9.0.9", "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", "@types/nodemailer": "^6.4.17", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", diff --git a/src/controller/api/productsController.ts b/src/controller/api/productsController.ts index cfe4700..e69de29 100644 --- a/src/controller/api/productsController.ts +++ b/src/controller/api/productsController.ts @@ -1,129 +0,0 @@ -// controllers/productsController.ts - -import express, { NextFunction, Request, Response } from "express"; -import { body, param } from "express-validator"; -import createHttpError from "http-errors"; -import { matchedData } from "express-validator"; -import * as productCategoriesRepository from "../../repository/productCategories"; // Repository untuk Product Categories -import * as productsRepository from "../../repository/products"; // Repository untuk Products -import validate from "../../middleware/expressValidatorErrorHandler"; // Validation middleware - -// POST: Tambah produk baru -const createProduct = [ - body("product_name").notEmpty().withMessage("Product name is required"), - body("description").optional().isString(), - body("price").notEmpty().isNumeric().withMessage("Price must be a number"), - body("product_category_id").notEmpty().isInt().withMessage("Invalid category ID"), // Ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const data = matchedData(req); - - // Cek apakah kategori produk ada di database - const categoryExists = await productCategoriesRepository.getProductCategoryById(data.product_category_id); - if (!categoryExists) return next(createHttpError(404, "Category not found")); - - // Simpan produk - const product = await productsRepository.createProduct(data); - - res.status(201).json({ message: "Product created", product }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// PATCH: Perbarui produk -const updateProduct = [ - param("id").isInt().withMessage("Invalid product ID"), // Ganti isMongoId() ke isInt() - body("product_name").optional().isString(), - body("description").optional().isString(), - body("price").optional().isNumeric().withMessage("Price must be a number"), - body("product_category_id").optional().isInt().withMessage("Invalid category ID"), // Ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const data = matchedData(req); - - // Jika ada perubahan kategori, cek apakah kategori tersebut ada - if (data.product_category_id) { - const categoryExists = await productCategoriesRepository.getProductCategoryById(data.product_category_id); - if (!categoryExists) return next(createHttpError(404, "Category not found")); - } - - // Update produk - const product = await productsRepository.updateProductById(Number(id), data); - if (!product) return next(createHttpError(404, "Product not found")); - - res.json({ message: "Product updated", product }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// DELETE: Hapus produk -const deleteProduct = [ - param("id").isInt().withMessage("Invalid product ID"), // Ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - - const product = await productsRepository.deleteProductById(Number(id)); - if (!product) return next(createHttpError(404, "Product not found")); - - res.json({ message: "Product deleted" }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// GET: Ambil satu produk berdasarkan ID -const getProduct = [ - param("id").isInt().withMessage("Invalid product ID"), // Ganti isMongoId() ke isInt() - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const product = await productsRepository.getProductById(Number(id)); - - if (!product) return next(createHttpError(404, "Product not found")); - - // Ambil kategori produk terkait (join dengan kategori produk) - const productCategory = await productCategoriesRepository.getProductCategoryById(product.product_category_id); - product.product_category = productCategory; // Menambahkan informasi kategori ke dalam produk - - res.json(product); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// GET: Ambil semua produk -const getProducts = async (req: Request, res: Response, next: NextFunction) => { - try { - const allProducts = await productsRepository.getAllProducts(); - - // Ambil kategori produk terkait untuk setiap produk - for (const product of allProducts) { - const category = await productCategoriesRepository.getProductCategoryById(product.product_category_id); - product.product_category = category; - } - - res.json(allProducts); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } -}; - -export default { - createProduct, - updateProduct, - deleteProduct, - getProduct, - getProducts -}; diff --git a/src/controller/api/stockForecasting.ts b/src/controller/api/stockForecasting.ts index 469e943..917859a 100644 --- a/src/controller/api/stockForecasting.ts +++ b/src/controller/api/stockForecasting.ts @@ -2,71 +2,83 @@ import express, { NextFunction, Request, Response } from "express"; import { body, param, query, matchedData } from "express-validator"; import createHttpError from "http-errors"; import validate from "../../middleware/expressValidatorErrorHandler"; -import { predictions, products } from "../../database/models"; +import { fileValidate, multiFileValidate } from "../../middleware/fileUploader"; +import axios from "axios"; // 🟢 **POST: Tambah Prediksi Stok Baru** -const createStockForecasting = [ - body("stock_sold").notEmpty().isNumeric().withMessage("Stock sold must be a number"), - body("stock_predicted").notEmpty().isNumeric().withMessage("Stock predicted must be a number"), - body("type").notEmpty().isString().withMessage("Type is required"), - body("accuracy").optional().isNumeric().withMessage("Accuracy must be a number"), - body("user_id").notEmpty().isMongoId().withMessage("Invalid user ID"), - body("product_id").notEmpty().isMongoId().withMessage("Invalid product ID"), - validate, +// const createStockForecasting = [ +// body("stock_sold").notEmpty().isNumeric().withMessage("Stock sold must be a number"), +// body("stock_predicted").notEmpty().isNumeric().withMessage("Stock predicted must be a number"), +// body("type").notEmpty().isString().withMessage("Type is required"), +// body("accuracy").optional().isNumeric().withMessage("Accuracy must be a number"), +// body("user_id").notEmpty().isMongoId().withMessage("Invalid user ID"), +// body("product_id").notEmpty().isMongoId().withMessage("Invalid product ID"), +// validate, +// async (req: Request, res: Response, next: NextFunction) => { +// try { +// const data = matchedData(req); + +// // Cek apakah produk ada +// const productExists = await products.findById(data.product_id); +// if (!productExists) return next(createHttpError(404, "Product not found")); + +// // Simpan data prediksi +// const prediction = new predictions(data); +// await prediction.save(); +// res.status(201).json({ message: "Stock forecasting created", prediction }); +// } catch (err) { +// next(createHttpError(500, "Internal Server Error", { cause: err })); +// } +// } +// ]; + +// // 🔵 **GET: Ambil Prediksi Berdasarkan Tanggal** +// const getStockForecastingByDate = [ +// query("date").notEmpty().isISO8601().withMessage("Invalid date format"), +// validate, +// async (req: Request, res: Response, next: NextFunction) => { +// try { +// const { date } = matchedData(req); +// const startDate = new Date(date); +// const endDate = new Date(date); +// endDate.setDate(endDate.getDate() + 1); + +// const forecasts = await predictions.find({ +// createdAt: { $gte: startDate, $lt: endDate } +// }).populate("product_id user_id"); + +// if (forecasts.length === 0) return next(createHttpError(404, "No stock forecasting found for this date")); + +// res.json(forecasts); +// } catch (err) { +// next(createHttpError(500, "Internal Server Error", { cause: err })); +// } +// } +// ]; + +// // 🟠 **GET: Ambil Semua Prediksi (History)** +// const getStockForecastings = async (req: Request, res: Response, next: NextFunction) => { +// try { +// const forecasts = await predictions.find().populate("product_id user_id"); +// res.json(forecasts); +// } catch (err) { +// next(createHttpError(500, "Internal Server Error", { cause: err })); +// } +// }; + +const makePredictionFromSheet = [ + fileValidate('sheet'), async (req: Request, res: Response, next: NextFunction) => { - try { - const data = matchedData(req); - - // Cek apakah produk ada - const productExists = await products.findById(data.product_id); - if (!productExists) return next(createHttpError(404, "Product not found")); - - // Simpan data prediksi - const prediction = new predictions(data); - await prediction.save(); - res.status(201).json({ message: "Stock forecasting created", prediction }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } + res.json({ + status: true, + message: 'File Saved' + }) } -]; - -// 🔵 **GET: Ambil Prediksi Berdasarkan Tanggal** -const getStockForecastingByDate = [ - query("date").notEmpty().isISO8601().withMessage("Invalid date format"), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { date } = matchedData(req); - const startDate = new Date(date); - const endDate = new Date(date); - endDate.setDate(endDate.getDate() + 1); - - const forecasts = await predictions.find({ - createdAt: { $gte: startDate, $lt: endDate } - }).populate("product_id user_id"); - - if (forecasts.length === 0) return next(createHttpError(404, "No stock forecasting found for this date")); - - res.json(forecasts); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🟠 **GET: Ambil Semua Prediksi (History)** -const getStockForecastings = async (req: Request, res: Response, next: NextFunction) => { - try { - const forecasts = await predictions.find().populate("product_id user_id"); - res.json(forecasts); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } -}; +] export default { - createStockForecasting, - getStockForecastingByDate, - getStockForecastings + // createStockForecasting, + // getStockForecastingByDate, + // getStockForecastings + makePredictionFromSheet }; diff --git a/src/controller/api/suppliers.ts b/src/controller/api/suppliers.ts deleted file mode 100644 index 5a17e54..0000000 --- a/src/controller/api/suppliers.ts +++ /dev/null @@ -1,101 +0,0 @@ -import express, { NextFunction, Request, Response } from "express"; -import { body, param, matchedData } from "express-validator"; -import createHttpError from "http-errors"; -import { suppliers } from "../../database/models"; -import validate from "../../middleware/expressValidatorErrorHandler"; - -// 🟢 **POST: Tambah Supplier Baru** -const createSupplier = [ - body("supplier_name").notEmpty().withMessage("Supplier name is required"), - body("contact").optional().isString().withMessage("Contact must be a string"), - body("address").optional().isString().withMessage("Address must be a string"), - body("user_id").notEmpty().isMongoId().withMessage("Invalid user ID"), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const data = matchedData(req); - - const supplier = new suppliers(data); - await supplier.save(); - - res.status(201).json({ message: "Supplier created", supplier }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🟠 **PATCH: Perbarui Supplier** -const updateSupplier = [ - param("id").isMongoId().withMessage("Invalid supplier ID"), - body("supplier_name").optional().isString(), - body("contact").optional().isString(), - body("address").optional().isString(), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const data = matchedData(req); - - const supplier = await suppliers.findByIdAndUpdate(id, data, { new: true }); - if (!supplier) return next(createHttpError(404, "Supplier not found")); - - res.json({ message: "Supplier updated", supplier }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🔴 **DELETE: Hapus Supplier** -const deleteSupplier = [ - param("id").isMongoId().withMessage("Invalid supplier ID"), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const supplier = await suppliers.findByIdAndDelete(id); - if (!supplier) return next(createHttpError(404, "Supplier not found")); - - res.json({ message: "Supplier deleted" }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🔵 **GET: Ambil Satu Supplier Berdasarkan ID** -const getSupplier = [ - param("id").isMongoId().withMessage("Invalid supplier ID"), - validate, - async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const supplier = await suppliers.findById(id).populate("user_id"); - - if (!supplier) return next(createHttpError(404, "Supplier not found")); - - res.json(supplier); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } - } -]; - -// 🟡 **GET: Ambil Semua Supplier** -const getSuppliers = async (req: Request, res: Response, next: NextFunction) => { - try { - const allSuppliers = await suppliers.find().populate("user_id"); - res.json(allSuppliers); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } -}; - -export default { - createSupplier, - updateSupplier, - deleteSupplier, - getSupplier, - getSuppliers -}; diff --git a/src/controller/api/suppliersController.ts b/src/controller/api/suppliersController.ts new file mode 100644 index 0000000..f565a2f --- /dev/null +++ b/src/controller/api/suppliersController.ts @@ -0,0 +1,121 @@ +import { body, matchedData, param, query } from "express-validator" +import expressValidatorErrorHandler from "../../middleware/expressValidatorErrorHandler" +import { NextFunction, Request, Response } from "express" +import { createSupplier, deleteSupplier, getSupplier, getSuppliers, updateSupplier } from "../../services/supplierServices" +import { TAPIResponse } from "../../types/core/http" +import { ISupplierTable } from "../../types/db-model" +import createHttpError from "http-errors" +import parsePhoneNumberFromString from "libphonenumber-js" + +const addSupplier = [ + body('supplier_name').notEmpty().isString(), + body('contact') + .optional({ nullable: true }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('address').optional().isString(), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + const reqData = matchedData(req) + const phone = parsePhoneNumberFromString(`+${reqData.contact}`); + if (!phone?.isValid()) + next(createHttpError(400, 'Invalid phone number format. Please match international format.')) + try { + const newSupplierId = await createSupplier({ + ...reqData, + user_id: req.user!.id + }) + const result: TAPIResponse = { + success: true, + message: 'New supplier successfully created.', + data: { + supplierId: newSupplierId + } + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const showAllSupplier = [ + query('page').optional().isInt(), + query('limit').optional().isInt(), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { page = 1, limit = 10 } = matchedData(req) + const suppliers = await getSuppliers(req.user!.id, page, limit); + const result: TAPIResponse = { + success: true, + data: suppliers + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const getSupplierDetail = [ + param('id'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req) + const supplier = await getSupplier(id) + const result: TAPIResponse = { + success: true, + data: supplier + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const updateSupplierRoute = [ + param('id'), + body('supplier_name').notEmpty().isString(), + body('contact') + .optional({ nullable: true }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('address').optional().isString(), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req, { locations: ['params'] }) + const reqBody = matchedData(req, { locations: ['body'] }) + const phone = parsePhoneNumberFromString(`+${reqBody.contact}`); + if (!phone?.isValid()) + next(createHttpError(400, 'Invalid phone number format. Please match international format.')) + await updateSupplier(id, reqBody) + const result: TAPIResponse = { + success: true, + message: 'Supplier data successfully updated' + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const deleteSupplierRoute = [ + param('id'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req) + await deleteSupplier(id) + const result: TAPIResponse = { + success: true, + message: 'Supplier successfully deleted' + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] +export default { addSupplier, showAllSupplier, getSupplierDetail, updateSupplierRoute, deleteSupplierRoute } \ No newline at end of file diff --git a/src/middleware/fileUploader.ts b/src/middleware/fileUploader.ts new file mode 100644 index 0000000..ff956e8 --- /dev/null +++ b/src/middleware/fileUploader.ts @@ -0,0 +1,62 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { root_path } from '../utils/core/storage'; + +const allowedExtensions = ['.xlsx', '.xls', '.csv', '.tsv', '.ods', '.json', '.txt', '.html', '.slk', '.dbf', '.prn', '.dif']; +const allowedMimeTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/csv', + 'application/vnd.oasis.opendocument.spreadsheet' +]; + +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadDir = root_path('src/temp'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: function (req, file, cb) { + const ext = path.extname(file.originalname); + const base = path.basename(file.originalname, ext); + const timestamp = Date.now(); + cb(null, `${base}-${timestamp}${ext}`); + } +}); + +export function fileValidate(fieldName: string) { + return multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const isValidMime = allowedMimeTypes.includes(file.mimetype); + const isValidExt = allowedExtensions.includes(ext); + + if (!isValidMime || !isValidExt) { + return cb(new Error('Unsupported file format for SheetJS') as any, false); + } + + cb(null, true); + } + }).single(fieldName); +} + +export function multiFileValidate(fields: multer.Field[]) { + return multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const isValidMime = allowedMimeTypes.includes(file.mimetype); + const isValidExt = allowedExtensions.includes(ext); + + if (!isValidMime || !isValidExt) { + return cb(new Error('Unsupported file format for SheetJS') as any, false); + } + + cb(null, true); + } + }).fields(fields); +} diff --git a/src/repository/suppliersRepository.ts b/src/repository/suppliersRepository.ts new file mode 100644 index 0000000..d7cb491 --- /dev/null +++ b/src/repository/suppliersRepository.ts @@ -0,0 +1,44 @@ +import { db } from "../database/MySQL" +import { ISupplierTable } from "../types/db-model" + +export const insertSupplier = async ({ + address, contact, supplier_name, user_id +}: ISupplierTable) => { + const [id] = await db('suppliers').insert({ + supplier_name, contact, user_id, address + }) + return id +} + +export const getSupplierById = (id: number) => { + return db('suppliers').where({ id }) +} + +export const getAllSupplier = (user_id: number, limit: number, offset: number) => { + return db('suppliers') + .select('*') + .where({ user_id }) + .limit(limit) + .offset(offset); +} + + +export const countSuppliers = async (id_user: number) => { + const result = await db('suppliers') + .where({ user_id: id_user }) + .count('id as count') + .first<{ + count: string; + }>(); + + return parseInt(result?.count || '0'); +}; + + +export const updateSupplierById = (id: number, data: ISupplierTable) => { + return getSupplierById(id).update(data) +} + +export const deleteSupplierById = (id: number) => { + return getSupplierById(id).delete() +} diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index f4b0beb..3736514 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -2,6 +2,7 @@ import express from 'express' var router = express.Router(); import authEndpoint from './auth' +import supplierEndpoint from './supplier' import authenticate from '../../middleware/authMiddleware'; // import productCategory from '../../controller/api/productCategoriesController'; // import product from '../../controller/api/productsController'; @@ -25,56 +26,6 @@ router.get('/is-authenticated', (req, res) => { }) }) -// ----- Product Categories -// router.post("/product-category", productCategory.createCategory); -// router.get("/product-categories", productCategory.getCategories); -// router.get("/product-category/:id", productCategory.getCategory); -// router.patch("/product-category/:id", productCategory.updateCategory); -// router.delete("/product-category/:id", productCategory.deleteCategory); - - -// ----- Products -// router.get("/product/:id", product.getProduct); -// router.post("/product", product.createProduct); -// router.patch("/product/:id", product.updateProduct); -// router.delete("/product/:id", product.deleteProduct); -// router.get("/products", product.getProducts); - - -// ----- Stock Forecasting -// router.post("/stock-forecasting", stockForecastingController.createStockForecasting); -// router.get("/stock-forecasting", stockForecastingController.getStockForecastingByDate); -// router.get("/stock-forecasting/history", stockForecastingController.getStockForecastings); - - -// ----- Stock Purchases -// router.post("/stock-purchase", stockPurchaseController.createStockPurchase); -// router.patch("/stock-purchase/:id", stockPurchaseController.updateStockPurchase); -// router.delete("/stock-purchase/:id", stockPurchaseController.deleteStockPurchase); -// router.get("/stock-purchase/:id", stockPurchaseController.getStockPurchase); -// router.get("/stock-purchase", stockPurchaseController.getStockPurchases); - - -// ----- Suppliers -// router.post("/supplier", supplierController.createSupplier); -// router.patch("/supplier/:id", supplierController.updateSupplier); -// router.delete("/supplier/:id", supplierController.deleteSupplier); -// router.get("/supplier/:id", supplierController.getSupplier); -// router.get("/supplier", supplierController.getSuppliers); - - -// ----- Transactions -// router.post("/transaction", transactionController.createTransaction); -// router.patch("/transaction/:id", transactionController.updateTransaction); -// router.delete("/transaction/:id", transactionController.deleteTransaction); -// router.get("/transaction/:id", transactionController.getTransaction); -// router.get("/transaction", transactionController.getTransactions); - -// ----- userFile -// router.post("/user-files", userFileController.createUserFile); -// router.patch("/user-files/:id", userFileController.updateUserFile); -// router.delete("/user-files/:id", userFileController.deleteUserFile); -// router.get("/user-files/:id", userFileController.getUserFile); -// router.get("/user-files", userFileController.getUserFiles); +router.use('/', supplierEndpoint) export default router; diff --git a/src/routes/api/prediction.ts b/src/routes/api/prediction.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/api/supplier.ts b/src/routes/api/supplier.ts new file mode 100644 index 0000000..4118b9d --- /dev/null +++ b/src/routes/api/supplier.ts @@ -0,0 +1,13 @@ +import express from 'express' +var router = express.Router(); +import supplierController from '../../controller/api/suppliersController'; +import authenticate from '../../middleware/authMiddleware'; + +router.use('/', authenticate) +router.post('/supplier', supplierController.addSupplier); +router.get('/suppliers', supplierController.showAllSupplier); +router.get('/supplier/:id', supplierController.getSupplierDetail); +router.patch('/supplier/:id', supplierController.updateSupplierRoute); +router.delete('/supplier/:id', supplierController.deleteSupplierRoute); + +export default router \ No newline at end of file diff --git a/src/services/supplierServices.ts b/src/services/supplierServices.ts new file mode 100644 index 0000000..254c0c9 --- /dev/null +++ b/src/services/supplierServices.ts @@ -0,0 +1,31 @@ +import { countSuppliers, deleteSupplierById, getAllSupplier, getSupplierById, insertSupplier, updateSupplierById } from "../repository/suppliersRepository"; +import { ISupplierTable } from "../types/db-model"; + +export const createSupplier = async (data: ISupplierTable) => insertSupplier(data) +export const getSuppliers = async ( + id_user: number, + page = 1, + limit = 10 +) => { + const safePage = Math.max(1, Number(page)); + const safeLimit = Math.max(1, Number(limit)); + const offset = (safePage - 1) * safeLimit; + + const [data, total] = await Promise.all([ + getAllSupplier(id_user, safeLimit, offset), + countSuppliers(id_user) + ]); + + return { + data, + meta: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.ceil(total / safeLimit) + } + }; +}; +export const getSupplier = async (id: number) => getSupplierById(id).first() +export const updateSupplier = async (id: number, data: ISupplierTable) => updateSupplierById(id, data) +export const deleteSupplier = async (id: number) => deleteSupplierById(id) \ No newline at end of file diff --git a/src/temp/dummy_time_series-1747066831499.xlsx b/src/temp/dummy_time_series-1747066831499.xlsx new file mode 100644 index 0000000..1c9ae46 Binary files /dev/null and b/src/temp/dummy_time_series-1747066831499.xlsx differ diff --git a/src/types/core/global/env.d.ts b/src/types/core/global/env.d.ts index 18dc8e2..06ffc45 100644 --- a/src/types/core/global/env.d.ts +++ b/src/types/core/global/env.d.ts @@ -5,6 +5,8 @@ declare namespace NodeJS { PORT: string; ALLOWED_ORIGIN: string; + PYTHON_API_HOST: string; + DB_CONNECTION: 'mysql' | string; DB_HOST: string; DB_PORT: string; diff --git a/src/types/core/global/http.d.ts b/src/types/core/global/http.d.ts index 1250d4f..a3c4a6f 100644 --- a/src/types/core/global/http.d.ts +++ b/src/types/core/global/http.d.ts @@ -1,6 +1,5 @@ import { IUser } from "../../db-model"; import { TAccessToken } from "../../jwt"; -import { TAPIResponse, TPaginatedResponse } from "../http"; declare module "express-serve-static-core" { interface Request { diff --git a/src/types/core/http.ts b/src/types/core/http.ts index c13b7b7..06c1bd3 100644 --- a/src/types/core/http.ts +++ b/src/types/core/http.ts @@ -5,11 +5,15 @@ export type TAPIResponse> = { data?: T; }; -export type TPaginatedResponse> = TAPIResponse & { - pagination?: { - total: number; +interface TPaginatedResult { + data: T[]; + meta: { page: number; - pageSize: number; + limit: number; + total: number; totalPages: number; - } -}; \ No newline at end of file + }; +} + +export type TPaginatedResponse> = + TAPIResponse>