diff --git a/package-lock.json b/package-lock.json index 0b5f764..25769c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "order-services-node", "version": "0.0.0", "dependencies": { + "@ngrok/ngrok": "^1.5.1", "amqplib": "^0.10.3", "axios": "^1.4.0", "bcrypt": "^5.1.1", @@ -158,6 +159,235 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.5.1.tgz", + "integrity": "sha512-sfcgdpiAJHqmuO3e6QjQGbavIrR3E72do/NAsnGhm+7SGstLj1aM3Sd8mkfTORb2Hj7ATMuoBYuED5ylKuRQCg==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.5.1", + "@ngrok/ngrok-darwin-arm64": "1.5.1", + "@ngrok/ngrok-darwin-universal": "1.5.1", + "@ngrok/ngrok-darwin-x64": "1.5.1", + "@ngrok/ngrok-freebsd-x64": "1.5.1", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.5.1", + "@ngrok/ngrok-linux-arm64-gnu": "1.5.1", + "@ngrok/ngrok-linux-arm64-musl": "1.5.1", + "@ngrok/ngrok-linux-x64-gnu": "1.5.1", + "@ngrok/ngrok-linux-x64-musl": "1.5.1", + "@ngrok/ngrok-win32-arm64-msvc": "1.5.1", + "@ngrok/ngrok-win32-ia32-msvc": "1.5.1", + "@ngrok/ngrok-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.5.1.tgz", + "integrity": "sha512-2Tokwi5GVWNLw3JEoM0Ieb/ypALniZu6fciUTgpuByutbKxOjvahD4fYOKwW3KMdV9bCb3XGGtWJCZXfRPPq1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.5.1.tgz", + "integrity": "sha512-HNOhrPDP+nJJY7Bh45DOeh6jmcGASWINGbUuseZM0C8psQMp7crPywjRh0inkRegUrb4K8y06sfmgt2fmsF6jQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.5.1.tgz", + "integrity": "sha512-EsMxYC/tY+ZqhjbeZtVq5MFIuD8SEPgAlHINEszsHd8ZRICc2U9Xl15CbDrew3pcfEg/ZVFrOH9CyC4aZ/V/cA==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.5.1.tgz", + "integrity": "sha512-H/x1BsYpAoTMhOtv4oYvwY6WHqbY0MsJ1XFcJQgrpAIjgmYqlwsnsUMHvEdBB/KY9kXF9DPgKUdRMfJwUIpwGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.5.1.tgz", + "integrity": "sha512-dY2W6HUv7e2xkpdfVj7fIk+5qmvrC7kVu6PJWJ8/rshW1FrU7qMcpnU53JvoQJRZzUf5k8xMNdx30zai/8mqYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.5.1.tgz", + "integrity": "sha512-JvbI/IIycw4Qq02ysyOBsSK5E0bZDgRqXSslHLTwuDAfw14lmrq2U0QkBeEOL8qwJ7wCwCH1PEOJacUyrqa9bg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.5.1.tgz", + "integrity": "sha512-yLFAlqTYYvH7QRg589HJarQGw1QrKQZcHiw0gm175eCqc+jpUG/Zcf8wohCTIJVLylMIzjDzVFSUsXC7UtMJdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.5.1.tgz", + "integrity": "sha512-momB/ZjjrxaGYOZ3YPAw1kT4DAfWT1x3dAHL0YoSVfNCpc8Fw0189ZAcxGn0hUFqkGDmSARS9o8b7hYd1b41oA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-fmMaz0b1Ry2CDLLn0mV8b9nLxqm0taQ2jYyn+C9OrazYNMT4XYYDKRQSm4UEaNoakdnoH+f2FsrWi/712GFxAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.5.1.tgz", + "integrity": "sha512-6Ajl9wpJSlvukl4WrkIw+WxVwAr7WTGnE35Voec6CERWtKMsO/F+BOSu3pfAa6iwxGK//JBpsTT1IwLLw7b2xQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.5.1.tgz", + "integrity": "sha512-JUH2yZxDPQGmQNT1d2KIu64u2k/R6uG1kEIXjcbsoff37v9aI6nUlzldRWB/wFSYkpZ4W/EuovM4Epar+fQOxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.5.1.tgz", + "integrity": "sha512-zS1JsMTJHnY+lPJFUwKnB5fzPm4GZCKeeZLehHrXP0LpQaKN8Y/vywqDGhuC0WtymvWE88+oreMV/6hQdviLSA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.5.1.tgz", + "integrity": "sha512-HegRwV9Gchh4p7K7sC6SPpWmFRwDEgwPByrb8tkuWDyP+EWNgpt3GKp8OAIK2xdWWHnN5VIwMa9u3COE/e5S8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/package.json b/package.json index c48674e..bdec4de 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "tsc --build" }, "dependencies": { + "@ngrok/ngrok": "^1.5.1", "amqplib": "^0.10.3", "axios": "^1.4.0", "bcrypt": "^5.1.1", diff --git a/src/app.ts b/src/app.ts index ca0290a..b6e9097 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,31 +10,40 @@ import helmet from 'helmet'; import compression from 'compression'; import cors from 'cors'; -// import internalRouter from './routes/internal'; import apiRouter from './routes/api'; import connectDB from './database/MySQL'; import { root_path } from './utils/core/storage'; import { respondWithError } from './middleware/core/errorHandler'; -// import schedule from 'node-schedule'; +import { getLocalIP } from './dev-core'; const app = express(); -// Middleware -const allowedOrigins = ['http://localhost:3000', 'https://myapp.com']; +const localIP = getLocalIP() -app.use(cors({ +// 🚨 1. Setup Allowed Origins +const allowedOrigins = ['http://localhost:3000', 'https://myapp.com', `http://${localIP}:3000`]; + +const corsOptions: cors.CorsOptions = { origin(origin, callback) { - if (!origin) return callback(null, true); - - if (allowedOrigins.includes(origin)) { - return callback(null, true); - } else { - return callback(new Error('Not allowed by CORS')); - } + if (!origin) return callback(null, true); // Allow server-to-server or curl + if (allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error('Not allowed by CORS')); }, - credentials: true -})); + credentials: true, +}; +// 🚨 2. Pasang CORS Middleware DULUAN +app.use(cors(corsOptions)); +// 💡 3. Handle all OPTIONS preflight +app.options('*', cors(corsOptions)); + +// 🔍 4. Logging semua request (termasuk OPTIONS) +app.use((req, _res, next) => { + console.log(`[${req.method}] ${req.originalUrl}`); + next(); +}); + +// 💨 Middleware lain app.use(compression()); app.use(helmet()); app.use(logger('dev')); @@ -43,40 +52,35 @@ app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); -// View engine setup -// app.set('views', path.join(__dirname, 'views')); +// 🖼️ View engine app.set('views', root_path('src/views')); app.set('view engine', 'ejs'); -// Database connection +// 🔌 DB connection connectDB(); -app.get('/', function (req, res, next) { +// ✅ Basic endpoint untuk cek server hidup +app.get('/', function (req, res) { res.json({ status: 'running', developer: 'Fahim', - project: 'Stock Prediction with ARIMA' + project: 'Stock Prediction with ARIMA', }); }); +// 🔗 API Router app.use('/api', apiRouter); -// 404 handler (opsional, bisa diaktifkan jika perlu) -// app.use((_req, _res, next) => { -// next(createHttpError(404)); -// }); - -// Scheduler (opsional, jika ingin dijalankan otomatis) -// const scheduleMidnight = schedule.scheduleJob('0 0 * * *', async () => { -// const result = await runningSchedule() ? 'success running midnight schedule' : 'failed running midnight schedule'; -// console.log(result); -// }); - -// const scheduleNoon = schedule.scheduleJob('0 12 * * *', async () => { -// const result = await runningSchedule() ? 'success running noon schedule' : 'failed running noon schedule'; -// console.log(result); -// }); +// 🚨 5. Tangani CORS error secara eksplisit +app.use(((err: Error, req: Request, res: Response, next: NextFunction) => { + if (err.message === 'Not allowed by CORS') { + console.error('❌ CORS error:', req.headers.origin); + return res.status(403).json({ error: 'CORS policy does not allow this origin.' }); + } + next(err); +}) as express.ErrorRequestHandler); +// ❗ 6. Global error handler (dari kamu) app.use(respondWithError); -export default app; +export default app; \ No newline at end of file diff --git a/src/controller/api/productCategoriesController.ts b/src/controller/api/productCategoriesController.ts index 47eb6a8..2914d88 100644 --- a/src/controller/api/productCategoriesController.ts +++ b/src/controller/api/productCategoriesController.ts @@ -1,94 +1,99 @@ -import express, { NextFunction, Request, Response } from 'express'; -import { body, matchedData, param } from 'express-validator'; -import createHttpError from 'http-errors'; -import { product_categories } from '../../database/models'; -import expressValidatorErrorHandler from '../../middleware/expressValidatorErrorHandler'; +import { body, matchedData, param } from "express-validator"; +import expressValidatorErrorHandler from "../../middleware/expressValidatorErrorHandler"; +import { NextFunction, Request, Response } from "express"; +import { IProductCategoryTable } from "../../types/db-model"; +import { createProductCategory, deleteProductCategory, showAllProductCategory, updateProductCategoryById } from "../../services/productCategoryServices"; +import createHttpError from "http-errors"; +import { TAPIResponse } from "../../types/core/http"; -const router = express.Router(); - -// POST: Tambah kategori baru -const createCategory = [ - body("category_name").notEmpty().withMessage("Category name is required"), - body("description").optional().isString(), - body("user_id").notEmpty().withMessage("User ID is required").isMongoId(), +const addProductCategory = [ + body("category_name").notEmpty().isString(), expressValidatorErrorHandler, async (req: Request, res: Response, next: NextFunction) => { + const reqBody = matchedData(req) try { - const data = matchedData(req); - const category = new product_categories(data); - await category.save(); - res.status(201).json({ message: "Category created", category }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); + const newProductId = await createProductCategory({ + ...reqBody, + category_name: reqBody.category_name.toLowerCase(), + user_id: req.user!.id + }) + const result: TAPIResponse = { + success: true, + message: 'New product category successfully created.', + data: { + productCategoryId: newProductId + } + } + res.json(result) + } catch (error: unknown) { + next(createHttpError(500, error as Error)) } - } -]; -// PATCH: Perbarui kategori -const updateCategory = [ - param("id").isMongoId().withMessage("Invalid category ID"), - body("category_name").optional().isString(), - body("description").optional().isString(), - expressValidatorErrorHandler, + } +] + +const getProductCategories = [ async (req: Request, res: Response, next: NextFunction) => { try { - const { id } = req.params; - const data = matchedData(req); - const category = await product_categories.findByIdAndUpdate(id, data, { new: true }); - if (!category) return next(createHttpError(404, "Category not found")); - res.json({ message: "Category updated", category }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); + const productCategories = await showAllProductCategory(req.user!.id) + console.log(productCategories) + const result: TAPIResponse = { + success: true, + data: productCategories + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) } } ] -// DELETE: Hapus kategori -const deleteCategory = [ - param("id").isMongoId().withMessage("Invalid category ID"), +const deleteProductCategoryRoute = [ + param('id'), expressValidatorErrorHandler, async (req: Request, res: Response, next: NextFunction) => { try { - const { id } = req.params; - const category = await product_categories.findByIdAndDelete(id); - if (!category) return next(createHttpError(404, "Category not found")); - res.json({ message: "Category deleted" }); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); + const { id } = matchedData(req) + await deleteProductCategory(id, req.user!.id) + const result: TAPIResponse = { + success: true, + message: 'Product category successfully deleted' + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) } } ] -// GET: Ambil satu kategori berdasarkan ID -const getCategory = [ - param("id").isMongoId().withMessage("Invalid category ID"), +const updateProductCategory = [ + param("id").notEmpty(), + body("category_name").notEmpty(), expressValidatorErrorHandler, async (req: Request, res: Response, next: NextFunction) => { + const reqParam = matchedData(req, { locations: ['params'] }) + const reqBody = matchedData(req, { locations: ['body'] }) try { - const { id } = req.params; - const category = await product_categories.findById(id); - if (!category) return next(createHttpError(404, "Category not found")); - res.json(category); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); + const newProductId = await updateProductCategoryById(reqParam.id, { + ...reqBody, + category_name: reqBody.category_name.toLowerCase() + }) + const result: TAPIResponse = { + success: true, + message: 'Product category successfully updated.', + data: { + productCategoryId: newProductId + } + } + res.json(result) + } catch (error: unknown) { + next(createHttpError(500, error as Error)) } + } ] -// GET: Ambil semua kategori -const getCategories = async (req: Request, res: Response, next: NextFunction) => { - try { - const categories = await product_categories.find(); - res.json(categories); - } catch (err) { - next(createHttpError(500, "Internal Server Error", { cause: err })); - } -} export default { - createCategory, - updateCategory, - deleteCategory, - getCategory, - getCategories, -}; + addProductCategory, getProductCategories, deleteProductCategoryRoute +} \ No newline at end of file diff --git a/src/controller/api/productsController.ts b/src/controller/api/productsController.ts index e69de29..a946896 100644 --- a/src/controller/api/productsController.ts +++ b/src/controller/api/productsController.ts @@ -0,0 +1,170 @@ +import { body, matchedData, param, query } from "express-validator" +import expressValidatorErrorHandler from "../../middleware/expressValidatorErrorHandler" +import { NextFunction, Request, Response } from "express" +import { TAPIResponse } from "../../types/core/http" +import { IProductTable, ISupplierTable } from "../../types/db-model" +import createHttpError from "http-errors" +import parsePhoneNumberFromString from "libphonenumber-js" +import { createProduct, deleteProductById, getProductByProductCode, getProducts, showProductById, updateProductById } from "../../services/productServices" + +const addProduct = [ + body('product_code').notEmpty().isString(), + body('product_name').notEmpty().isString(), + body('stock') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('selling_price') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('buying_price') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('product_category_id') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + const reqData = matchedData(req) + try { + const newProductId = await createProduct({ + ...reqData, + buying_price: reqData.buying_price || null, + selling_price: reqData.selling_price || null, + stock: reqData.stock || 0, + product_category_id: reqData.product_category_id || null, + user_id: req.user!.id + }) + const result: TAPIResponse = { + success: true, + message: 'New product successfully created.', + data: { + productId: newProductId + } + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const showAllProducts = [ + query('page').optional().isInt(), + query('limit').optional().isInt(), + async (req: Request, res: Response, next: NextFunction) => { + try { + const { page = 1, limit = 10 } = matchedData(req) + const products = await getProducts(req.user!.id, page, limit); + const result: TAPIResponse = { + success: true, + data: products + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const getProductDetail = [ + param('id'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req) + const product = await showProductById(id) + const result: TAPIResponse = { + success: true, + data: product + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const updateProductRoute = [ + param('id').notEmpty(), + body('product_code').notEmpty().isString(), + body('product_name').notEmpty().isString(), + body('stock') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('selling_price') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('buying_price') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + body('product_category_id') + .optional({ values: 'falsy' }) + .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req, { locations: ['params'] }) + const reqBody = matchedData(req, { locations: ['body'] }) + await updateProductById(id, { + ...reqBody, + buying_price: reqBody.buying_price || null, + selling_price: reqBody.selling_price || null, + stock: reqBody.stock || 0, + product_category_id: reqBody.product_category_id || null, + }) + const result: TAPIResponse = { + success: true, + message: 'Product data successfully updated' + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const deleteProductRoute = [ + param('id'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = matchedData(req) + await deleteProductById(id) + const result: TAPIResponse = { + success: true, + message: 'Product successfully deleted' + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +const getProductByProductCodeRoute = [ + param('product_code'), + expressValidatorErrorHandler, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { product_code } = matchedData(req) + const product = await getProductByProductCode( + req.user!.id, + product_code, + ) + + if (!product) { + return next(createHttpError(404, 'Produk tidak ditemukan')) + } + + const result: TAPIResponse = { + success: true, + data: product + } + res.json(result) + } catch (error) { + next(createHttpError(500, error as Error)) + } + } +] + +export default { addProduct, showAllProducts, getProductDetail, updateProductRoute, deleteProductRoute, getProductByProductCodeRoute } \ No newline at end of file diff --git a/src/controller/api/suppliersController.ts b/src/controller/api/suppliersController.ts index f565a2f..604bd84 100644 --- a/src/controller/api/suppliersController.ts +++ b/src/controller/api/suppliersController.ts @@ -10,18 +10,19 @@ import parsePhoneNumberFromString from "libphonenumber-js" const addSupplier = [ body('supplier_name').notEmpty().isString(), body('contact') - .optional({ nullable: true }) + .optional({ values: 'falsy' }) .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.')) + if (!phone?.isValid() && !!reqData.contact) + return next(createHttpError(400, 'Invalid phone number format. Please match international format.')) try { const newSupplierId = await createSupplier({ ...reqData, + contact: reqData.contact || null, user_id: req.user!.id }) const result: TAPIResponse = { @@ -78,7 +79,7 @@ const updateSupplierRoute = [ param('id'), body('supplier_name').notEmpty().isString(), body('contact') - .optional({ nullable: true }) + .optional({ values: 'falsy' }) .isNumeric({ no_symbols: true }).withMessage('Contact must contain only digits (0-9), no symbols allowed'), body('address').optional().isString(), expressValidatorErrorHandler, @@ -87,9 +88,12 @@ const updateSupplierRoute = [ 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) + if (!phone?.isValid() && !!reqBody.contact) + return next(createHttpError(400, 'Invalid phone number format. Please match international format.')) + await updateSupplier(id, { + ...reqBody, + contact: reqBody.contact || null + }) const result: TAPIResponse = { success: true, message: 'Supplier data successfully updated' diff --git a/src/controller/api/transactions.ts b/src/controller/api/transactions.ts index 06e622a..d32e961 100644 --- a/src/controller/api/transactions.ts +++ b/src/controller/api/transactions.ts @@ -4,7 +4,7 @@ import { body, param, matchedData } from "express-validator"; import createHttpError from "http-errors"; import validate from "../../middleware/expressValidatorErrorHandler"; // Validation middleware import * as transactionsRepository from "../../repository/transaction"; // Repository untuk Transactions dalam bentuk mongoose -import * as productsRepository from "../../repository/products"; // Untuk cek produk +import * as productsRepository from "../../repository/productsRepository"; // Untuk cek produk import { NextFunction, Request, Response } from "express"; import { ITransaction } from "../../types/db-model"; diff --git a/src/dev-core.ts b/src/dev-core.ts new file mode 100644 index 0000000..e66ec97 --- /dev/null +++ b/src/dev-core.ts @@ -0,0 +1,13 @@ +import os from 'os'; + +export function getLocalIP(): string { + const interfaces = os.networkInterfaces(); + for (const name in interfaces) { + for (const iface of interfaces[name]!) { + if (iface.family === "IPv4" && !iface.internal) { + return iface.address; + } + } + } + return "127.0.0.1"; +} \ No newline at end of file diff --git a/src/repository/productCategoriesRepository.ts b/src/repository/productCategoriesRepository.ts new file mode 100644 index 0000000..4b13ff4 --- /dev/null +++ b/src/repository/productCategoriesRepository.ts @@ -0,0 +1,19 @@ +import { db } from "../database/MySQL"; +import { IProductCategoryTable } from "../types/db-model"; + +export async function insertProductCategory(data: IProductCategoryTable) { + const [id] = await db('product_categories').insert(data) + return id +} + +export function selectAllProductCategory(user_id: IProductCategoryTable['user_id']) { + return db('product_categories').where({ user_id }) +} + +export function selectProductCategoryById(id: IProductCategoryTable['id']) { + return db('product_categories').where({ id }) +} + +export function selectProductCategoryByName(category_name: IProductCategoryTable['category_name']) { + return db('product_categories').where({ category_name }) +} \ No newline at end of file diff --git a/src/repository/products.ts b/src/repository/products.ts deleted file mode 100644 index 03d319e..0000000 --- a/src/repository/products.ts +++ /dev/null @@ -1,64 +0,0 @@ -// repository/productsRepository.ts - -import { db } from "../database/MySQL"; // Koneksi database menggunakan Knex -import { IProduct } from "../types/db-model"; - -const TABLE_NAME = "products"; - -// Tambah produk baru -export const createProduct = async (data: IProduct) => { - try { - const [product] = await db(TABLE_NAME).insert({ - product_name: data.product_name, - price: data.price, - product_category_id: data.product_category_id, - }).returning("*"); - - return product; - } catch (err) { - throw new Error("Error creating product"); - } -}; - -// Update produk by ID -export const updateProductById = async (id: number, data: Partial) => { - try { - const [updatedProduct] = await db(TABLE_NAME) - .where({ id }) - .update(data) - .returning("*"); - - return updatedProduct; - } catch (err) { - throw new Error("Error updating product"); - } -}; - -// Hapus produk by ID -export const deleteProductById = async (id: number) => { - try { - const deleted = await db(TABLE_NAME).where({ id }).del(); - return deleted; - } catch (err) { - throw new Error("Error deleting product"); - } -}; - -// Ambil produk by ID -export const getProductById = async (id: number) => { - try { - const product = await db(TABLE_NAME).where({ id }).first(); - return product; - } catch (err) { - throw new Error("Error fetching product"); - } -}; - -// Ambil semua produk -export const getAllProducts = async () => { - try { - return await db(TABLE_NAME); - } catch (err) { - throw new Error("Error fetching all products"); - } -}; diff --git a/src/repository/productsRepository.ts b/src/repository/productsRepository.ts new file mode 100644 index 0000000..8b03036 --- /dev/null +++ b/src/repository/productsRepository.ts @@ -0,0 +1,58 @@ +import { db } from "../database/MySQL"; +import { IProductTable } from "../types/db-model"; + +export const insertProduct = async (data: IProductTable) => { + const [id] = await db('products').insert(data) + return id +} + +export const selectProductsByUserId = (user_id: IProductTable['user_id']) => { + return db('products').where({ user_id }) +} + +export const selectProductById = (id: IProductTable['id']) => { + return db('products').where({ id }) +} + +export const getAllProducts = (user_id: number, limit: number, offset: number) => { + return db('products as p') + .leftJoin('product_categories as c', 'p.product_category_id', 'c.id') + .select('p.*', 'c.category_name') + .where('p.user_id', user_id) + .where('p.deleted', false) + .limit(limit) + .offset(offset) +} + +export const countProducts = async (id_user: number) => { + const result = await db('products') + .where({ user_id: id_user, deleted: false }) + .count('id as count') + .first<{ + count: string; + }>(); + + return parseInt(result?.count || '0'); +}; + +export const selectProductByCategoryId = (product_category_id: IProductTable['product_category_id']) => { + return db('products').where({ product_category_id }) +} + +export const countProductsByCategory = async (product_category_id: IProductTable['product_category_id']) => { + const result = await db('products') + .where({ product_category_id }) + .count('id as count') + .first<{ + count: string; + }>(); + + return parseInt(result?.count || '0'); +}; + +export const selectProductByProductCode = (product_code: IProductTable['product_code'], user_id: IProductTable['user_id']) => { + return db('products').where({ + product_code, + user_id, + }) +} \ No newline at end of file diff --git a/src/routes/api/auth.ts b/src/routes/api/auth.ts index c07f9bd..5721101 100644 --- a/src/routes/api/auth.ts +++ b/src/routes/api/auth.ts @@ -1,6 +1,7 @@ import express from 'express' var router = express.Router(); import authRoute from '../../controller/api/authController'; +import authenticate from '../../middleware/authMiddleware'; router.post('/register', authRoute.register); router.post('/login', authRoute.login); @@ -10,6 +11,6 @@ router.patch('/forgot-password/:token', authRoute.forgotPasswordChangePassword); router.get('/verify/:token', authRoute.verify); router.post('/re-send-email-activation/:token', authRoute.resendEmailVerification); router.get('/refresh-token', authRoute.refreshToken); -router.get('/logout', authRoute.logout); +router.get('/logout', authenticate, authRoute.logout); export default router \ No newline at end of file diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 3736514..36ae95c 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -3,15 +3,9 @@ var router = express.Router(); import authEndpoint from './auth' import supplierEndpoint from './supplier' +import productCategoryEndpoint from './product_category' +import productEndpoint from './product' import authenticate from '../../middleware/authMiddleware'; -// import productCategory from '../../controller/api/productCategoriesController'; -// import product from '../../controller/api/productsController'; -// import stockForecastingController from '../../controller/api/stockForecasting'; -// import stockPurchaseController from '../../controller/api/stockPurchases'; -// import supplierController from '../../controller/api/suppliers'; -// import transactionController from '../../controller/api/transactions'; -// import userFileController from '../../controller/api/userFiles'; -// const {jwtInfluencerMiddleware} = require("../middleware/authMiddleware"); router.get('/', function (req, res, next) { res.send('Read docs.'); @@ -27,5 +21,7 @@ router.get('/is-authenticated', (req, res) => { }) router.use('/', supplierEndpoint) +router.use('/', productEndpoint) +router.use('/', productCategoryEndpoint) export default router; diff --git a/src/routes/api/product.ts b/src/routes/api/product.ts new file mode 100644 index 0000000..fec5f8a --- /dev/null +++ b/src/routes/api/product.ts @@ -0,0 +1,14 @@ +import express from 'express' +var router = express.Router(); +import productController from '../../controller/api/productsController'; +import authenticate from '../../middleware/authMiddleware'; + +router.use('/', authenticate) +router.post('/product', productController.addProduct); +router.get('/product/:product_code', productController.getProductByProductCodeRoute); +router.get('/products', productController.showAllProducts); +router.get('/product/:id', productController.getProductDetail); +router.patch('/product/:id', productController.updateProductRoute); +router.delete('/product/:id', productController.deleteProductRoute); + +export default router \ No newline at end of file diff --git a/src/routes/api/product_category.ts b/src/routes/api/product_category.ts new file mode 100644 index 0000000..aa1e696 --- /dev/null +++ b/src/routes/api/product_category.ts @@ -0,0 +1,12 @@ +import express from 'express' +var router = express.Router(); +import productController from '../../controller/api/productCategoriesController'; +import authenticate from '../../middleware/authMiddleware'; + +router.use('/', authenticate) +router.post('/product-category', productController.addProductCategory); +router.post('/product-category/:id', productController.addProductCategory); +router.get('/product-categories', productController.getProductCategories); +router.delete('/product-category/:id', productController.deleteProductCategoryRoute); + +export default router \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 7ba87d1..269883a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,14 @@ -// src/server.ts import app from './app'; import http from 'http'; +import os from 'os'; +import { getLocalIP } from './dev-core'; -const PORT = process.env.PORT || 3000; +const PORT = parseInt(process.env.PORT || "5000", 10); + +const localIP = getLocalIP(); const server = http.createServer(app); -server.listen(PORT, () => { - console.log(`🚀 Server running at http://localhost:${PORT}`); +server.listen(PORT, "0.0.0.0", () => { + console.log(`🚀 Server running at http://${localIP}:${PORT}`); }); diff --git a/src/services/productCategoryServices.ts b/src/services/productCategoryServices.ts new file mode 100644 index 0000000..abeebfc --- /dev/null +++ b/src/services/productCategoryServices.ts @@ -0,0 +1,26 @@ +import { insertProductCategory, selectAllProductCategory, selectProductCategoryById, selectProductCategoryByName } from "../repository/productCategoriesRepository"; +import { selectProductByCategoryId } from "../repository/productsRepository"; +import { IProductCategoryTable } from "../types/db-model"; + +export const createProductCategory = (data: IProductCategoryTable) => insertProductCategory(data) + +export const showAllProductCategory = (user_id: IProductCategoryTable['user_id']) => selectAllProductCategory(user_id) + +export const deleteProductCategory = async (id: IProductCategoryTable['id'], user_id: IProductCategoryTable['user_id']) => { + const otherCategory = await selectProductCategoryByName('Other').first() + let otherCategoryId = otherCategory?.id + if (!otherCategoryId) { + otherCategoryId = await createProductCategory({ + category_name: 'Other', + user_id, + } as IProductCategoryTable) + } + await selectProductByCategoryId(id).where({ + product_category_id: id + }).update({ + product_category_id: otherCategoryId + }) + return selectProductCategoryById(id).delete() +} + +export const updateProductCategoryById = async (id: IProductCategoryTable['id'], data: IProductCategoryTable) => selectProductCategoryById(id).update(data) \ No newline at end of file diff --git a/src/services/productServices.ts b/src/services/productServices.ts new file mode 100644 index 0000000..b23e15b --- /dev/null +++ b/src/services/productServices.ts @@ -0,0 +1,48 @@ +import { countProducts, getAllProducts, insertProduct, selectProductById, selectProductByProductCode, selectProductsByUserId } from "../repository/productsRepository" +import { IProductTable } from "../types/db-model" + +export const createProduct = async (data: IProductTable) => { + const product = await selectProductByProductCode(data.product_code, data.user_id).first() + if (product) { + return await updateProductById(product.id, { + deleted: false + }) + } else { + return await insertProduct(data) + } +} +export const getProducts = 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([ + getAllProducts(id_user, safeLimit, offset), + countProducts(id_user) + ]); + + return { + data, + meta: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.ceil(total / safeLimit) + } + }; +}; +export const showProductById = async (id: IProductTable['id']) => selectProductById(id).first() +export const updateProductById = async (id: IProductTable['id'], data: Partial) => selectProductById(id).update(data) +export const deleteProductById = async (id: IProductTable['id']) => { + return selectProductById(id).update({ + deleted: true + }) +} +export const getProductByProductCode = ( + user_id: IProductTable['user_id'], + product_code: IProductTable['product_code'] +) => selectProductByProductCode(product_code, user_id).first() \ No newline at end of file diff --git a/src/types/db-model.ts b/src/types/db-model.ts index 780bc70..3960fe9 100644 --- a/src/types/db-model.ts +++ b/src/types/db-model.ts @@ -13,22 +13,24 @@ export interface IProductCategoryTable { user_id: number; category_name: string; id: number; - description: string; } export interface IProductTable { id: number; product_name: string; + product_code: string; stock: number; - price: number; + buying_price: number | null; + selling_price: number | null; + product_category_id: number | null; user_id: number; - product_category_id: number; + deleted: boolean } export interface ISupplierTable { id: number; supplier_name: string; - contact: string; + contact: string | null; address: string; user_id: number; }