first commit

This commit is contained in:
Rynare 2025-05-08 23:24:35 +07:00
commit f5639c6615
40 changed files with 6219 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
/.idea/
node_modules/
uploads/*
.env
.env.example
cloud_sql_proxy
uploads/
bin/build
ngrok
.DS_Store

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:14
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
# If you are building your code for production
#RUN npm ci --only=production
COPY . .
EXPOSE 5698
CMD [ "node", "./bin/www" ]

90
bin/www Normal file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const app = require('./build/app');
const debug = require('debug')('order-services-node:server');
const http = require('http');
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

4023
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "order-services-node",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www",
"dev": "npx ts-node-dev --respawn --transpile-only ./src/server.ts",
"preview": "npx ts-node-dev ./bin/build/app.ts",
"build": "tsc --build"
},
"dependencies": {
"amqplib": "^0.10.3",
"axios": "^1.4.0",
"bcrypt": "^5.1.1",
"compression": "^1.7.4",
"cookie-parser": "~1.4.4",
"cors": "^2.8.5",
"debug": "~2.6.9",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-validator": "^7.2.1",
"helmet": "^7.1.0",
"http-errors": "~1.6.3",
"jsonwebtoken": "^9.0.0",
"knex": "^2.5.1",
"moment": "^2.29.4",
"mongodb": "^6.14.0",
"mongoose": "^8.11.0",
"morgan": "~1.9.1",
"mysql": "^2.18.1",
"mysql2": "^3.14.1",
"node-schedule": "^2.1.1",
"nodemailer": "^6.10.0",
"nodemon": "^3.1.9",
"pg": "^8.11.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/morgan": "^1.9.9",
"@types/nodemailer": "^6.4.17",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}

82
src/app.ts Normal file
View File

@ -0,0 +1,82 @@
// src/app.ts
import dotenv from 'dotenv';
dotenv.config();
import express, { Request, Response, NextFunction } from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import logger from 'morgan';
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';
const app = express();
// Middleware
const allowedOrigins = ['http://localhost:3000', 'https://myapp.com'];
app.use(cors({
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'));
}
},
credentials: true
}));
app.use(compression());
app.use(helmet());
app.use(logger('dev'));
app.use(express.json());
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'));
app.set('views', root_path('src/views'));
app.set('view engine', 'ejs');
// Database connection
connectDB();
app.get('/', function (req, res, next) {
res.json({
status: 'running',
developer: 'Fahim',
project: 'Stock Prediction with ARIMA'
});
});
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);
// });
app.use(respondWithError);
export default app;

View File

@ -0,0 +1,394 @@
import dotenv from 'dotenv';
dotenv.config();
import bcrypt from 'bcrypt';
import jwt, { VerifyCallback } from 'jsonwebtoken';
import nodemailer from 'nodemailer';
import createHttpError from 'http-errors';
import { generateAccessToken, generateRefreshToken } from '../../utils/api/generateCredentialToken';
import { body, matchedData, param } from 'express-validator';
import expressValidatorErrorHandler from '../../middleware/expressValidatorErrorHandler';
import { NextFunction, Request, Response } from 'express';
import { createUser } from '../../repository/usersRepository';
import { activateUser, getUserByEmail, getUserByRefreshToken, updateUserById } from '../../services/userServices';
import { TAPIResponse } from '../../types/core/http';
const saltRounds = 10;
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
type TAccountActivationToken = {
email: string
}
const register = [
body('email')
.notEmpty().withMessage('Email is required')
.normalizeEmail()
.isEmail().withMessage('Invalid email format'),
body('name')
.optional()
.isString().withMessage('Name must be a string')
.isLength({ min: 4, max: 20 }).withMessage('Name must be between 4 and 20 characters'),
body('password')
.notEmpty().withMessage('Password is required')
.isStrongPassword({ minLength: 8 }).withMessage('Password must be strong (min 8 chars, uppercase, lowercase, number, and symbol)'),
body('emailActivationPage')
.optional().isString(),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, name, password, emailActivationPage = process.env.HOST } = matchedData(req);
// Cek apakah user sudah terdaftar
const existingUser = await getUserByEmail(email);
if (existingUser) return next(createHttpError(400, "Email already exists"));
// Hash password
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Buat token verifikasi
const activationToken = jwt.sign({ email } as TAccountActivationToken, process.env.JWT_SECRET, { expiresIn: '1h' });
// Simpan user
const newUser = await createUser({ email, name: name ?? email, password: hashedPassword });
// Kirim email verifikasi
const mailOptions = {
from: process.env.EMAIL_USER,
to: email,
subject: "Verify Your Email",
text: `Click this link to verify your email: ${emailActivationPage}/${activationToken}`
};
transporter.sendMail(mailOptions, (err) => {
if (err) return next(createHttpError(500, "Error sending verification email", { cause: err }));
});
const resultResponse: TAPIResponse = {
success: true,
message: "User registered. Please check your email to verify your account. Please check your email inbox or spam inbox."
}
if (process.env.NODE_ENV === 'development') {
console.log(mailOptions)
}
res.status(201).json(resultResponse);
} catch (err) {
next(createHttpError(500, "Error registering user", { cause: err }));
}
}
];
const verify = [
param('token').isString().withMessage('Token must be string'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = matchedData(req);
const decoded = jwt.verify(token, process.env.JWT_SECRET) as TAccountActivationToken;
// Update status verifikasi
const user = await getUserByEmail(decoded.email)
if (!user) return next(createHttpError(400, "Invalid Token"));
await activateUser(decoded.email)
const resultResponse: TAPIResponse = {
success: true,
message: "Email verified successfully. You can now log in."
}
res.json(resultResponse);
} catch (err: any) {
next(createHttpError(400, "Invalid or expired token", { cause: err }));
}
}
];
const resendEmailVerification = [
param('token').isString().withMessage('Token must be string'),
body('emailActivationPage')
.optional().isString(),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, emailActivationPage } = matchedData(req);
const decoded = jwt.decode(token) as TAccountActivationToken
const user = await getUserByEmail(decoded.email)
if (!user) return next(createHttpError(400, "Invalid Token"));
if (user.is_verified) return next(createHttpError(409, "This account has already been verified. You can log in directly."));
const activationToken = jwt.sign({ email: decoded.email } as TAccountActivationToken, process.env.JWT_SECRET, { expiresIn: '1h' });
const mailOptions = {
from: process.env.EMAIL_USER,
to: decoded.email,
subject: "Verify Your Email",
text: `Click this link to verify your email: ${emailActivationPage}/${activationToken}`
};
transporter.sendMail(mailOptions, (err) => {
if (err) return next(createHttpError(500, "Error sending verification email", { cause: err }));
});
const resultResponse: TAPIResponse = {
success: true,
message: "Verification email has been sent successfully. Please check your email inbox or spam inbox."
}
if (process.env.NODE_ENV === 'development') {
console.log(mailOptions)
}
res.json(resultResponse)
} catch (err) {
next(createHttpError(400, "Invalid or expired token", { cause: err }));
}
}
];
const login = [
body('email')
.notEmpty().withMessage('Email is required')
.normalizeEmail()
.isEmail().withMessage('Invalid email format'),
body('password')
.notEmpty().withMessage('Password is required'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = matchedData(req);
// Cari user
const user = await getUserByEmail(email);
if (!user) return next(createHttpError(400, "Invalid credentials"));
// Cek apakah email sudah diverifikasi
if (!user.is_verified) return next(createHttpError(400, "Please verify your email first"));
// Bandingkan password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return next(createHttpError(400, "Invalid credentials"))
};
// Buat tokens
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Simpan refresh token di database
await updateUserById(user.id, {
refresh_token: refreshToken
})
// Kirim token sebagai cookie
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 15 * 24 * 60 * 60 * 1000
});
const resultResponse: TAPIResponse = {
success: true,
message: `Welcome back, ${user.name}`,
data: { accessToken }
}
res.json(resultResponse);
} catch (err) {
next(createHttpError(500, "Error logging in", { cause: err }));
}
}
];
const refreshToken = [
async (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return next(createHttpError(401, "No refresh token provided"));
// Cari user dengan refresh token
const user = await getUserByRefreshToken(refreshToken);
if (!user) return next(createHttpError(403, "Invalid refresh token"));
const jwtVerifCB: VerifyCallback = (err, decoded) => {
if (err) return next(createHttpError(403, "Invalid refresh token"));
// Buat access token baru
const accessToken = generateAccessToken(user);
const resultResponse: TAPIResponse = {
success: true,
data: { accessToken }
}
res.json(resultResponse);
}
// Verifikasi refresh token
jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, jwtVerifCB);
} catch (err) {
next(createHttpError(500, "Error refreshing token", { cause: err }));
}
}
];
const logout = [
async (req: Request, res: Response, next: NextFunction) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
res.sendStatus(204);
return
} // Tidak ada token, langsung selesai
// Hapus refresh token dari database
await updateUserById(req.user!.id, {
refresh_token: null
})
res.clearCookie("refreshToken", { httpOnly: true, secure: process.env.NODE_ENV === "production" });
const resultResponse: TAPIResponse = {
success: true,
message: "Logged out successfully"
}
res.json(resultResponse);
} catch (err) {
next(createHttpError(500, "Error logging out", { cause: err }));
}
}
];
const forgotPasswordSendEmail = [
body('email')
.notEmpty().withMessage('Email is required')
.normalizeEmail()
.isEmail().withMessage('Invalid email format'),
body('emailActivationPage')
.optional().isString(),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, emailActivationPage } = matchedData(req);
// Cari user
const user = await getUserByEmail(email);
if (user) {
if (!user.is_verified) return next(createHttpError(403, "Please verify your email first"));
const forgotPasswordToken = jwt.sign({ email } as TAccountActivationToken, process.env.JWT_SECRET, { expiresIn: '1h' });
const mailOptions = {
from: process.env.EMAIL_USER,
to: email,
subject: "Forgot Password Link",
text: `Click this link to change your password: ${emailActivationPage}/${forgotPasswordToken}`
};
transporter.sendMail(mailOptions, (err) => {
if (err) return next(createHttpError(500, "Error sending forgot password link email", { cause: err }));
});
const resultResponse: TAPIResponse = {
success: true,
message: "Forgot password link has been sent successfully. Please check your email inbox or spam inbox."
}
if (process.env.NODE_ENV === 'development') {
console.log(mailOptions)
}
res.json(resultResponse);
} else {
return next(createHttpError(400, "Invalid credentials"))
}
} catch (err) {
next(createHttpError(500, "Failed to generate forgot password token.", { cause: err }));
}
}
];
const forgotPasswordVerifyToken = [
param('token').isString().withMessage('Token must be string'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = matchedData(req);
const decoded = jwt.verify(token, process.env.JWT_SECRET) as TAccountActivationToken;
// Update status verifikasi
const user = await getUserByEmail(decoded.email)
if (!user) return next(createHttpError(400, "Invalid Token"));
await activateUser(decoded.email)
const resultResponse: TAPIResponse = {
success: true,
message: "Token is Valid."
}
res.json(resultResponse);
} catch (err: any) {
next(createHttpError(400, "Invalid or expired token", { cause: err }));
}
}
];
const forgotPasswordChangePassword = [
param('token').isString().withMessage('Token must be string'),
body('password')
.notEmpty().withMessage('Password is required')
.isStrongPassword({ minLength: 8 }).withMessage('Password must be strong (min 8 chars, uppercase, lowercase, number, and symbol)'),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = matchedData(req);
// Cari user
const decoded = jwt.verify(token, process.env.JWT_SECRET) as TAccountActivationToken;
const user = await getUserByEmail(decoded.email);
if (!user) return next(createHttpError(400, "User not found."));
// Cek apakah email sudah diverifikasi
if (!user.is_verified) return next(createHttpError(400, "Please verify your email first"));
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Simpan refresh token di database
await updateUserById(user.id, {
password: hashedPassword
})
const resultResponse: TAPIResponse = {
success: true,
message: `Password changed successful.`
}
res.json(resultResponse);
} catch (err) {
next(createHttpError(500, "Error logging in", { cause: err }));
}
}
];
export default {
register,
verify, resendEmailVerification,
login,
forgotPasswordSendEmail, forgotPasswordVerifyToken, forgotPasswordChangePassword,
refreshToken,
logout,
};

View File

@ -0,0 +1,94 @@
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';
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(),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
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 }));
}
}
];
// PATCH: Perbarui kategori
const updateCategory = [
param("id").isMongoId().withMessage("Invalid category ID"),
body("category_name").optional().isString(),
body("description").optional().isString(),
expressValidatorErrorHandler,
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 }));
}
}
]
// DELETE: Hapus kategori
const deleteCategory = [
param("id").isMongoId().withMessage("Invalid category 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 }));
}
}
]
// GET: Ambil satu kategori berdasarkan ID
const getCategory = [
param("id").isMongoId().withMessage("Invalid category ID"),
expressValidatorErrorHandler,
async (req: Request, res: Response, next: NextFunction) => {
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 }));
}
}
]
// 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,
};

View File

@ -0,0 +1,129 @@
// 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
};

View File

@ -0,0 +1,72 @@
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";
// 🟢 **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,
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 }));
}
};
export default {
createStockForecasting,
getStockForecastingByDate,
getStockForecastings
};

View File

@ -0,0 +1,115 @@
import express, { NextFunction, Request, Response } from "express";
import { body, param, matchedData } from "express-validator";
import createHttpError from "http-errors";
import { purchases, products, suppliers } from "../../database/models";
import validate from "../../middleware/expressValidatorErrorHandler";
// 🟢 **POST: Tambah Pembelian Stok Baru**
const createStockPurchase = [
body("amount").notEmpty().isNumeric().withMessage("Amount must be a number"),
body("total_price").notEmpty().isNumeric().withMessage("Total price must be a number"),
body("user_id").notEmpty().isMongoId().withMessage("Invalid user ID"),
body("product_id").notEmpty().isMongoId().withMessage("Invalid product ID"),
body("supplier_id").notEmpty().isMongoId().withMessage("Invalid supplier 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"));
// Cek apakah supplier ada
const supplierExists = await suppliers.findById(data.supplier_id);
if (!supplierExists) return next(createHttpError(404, "Supplier not found"));
// Simpan data pembelian
const purchase = new purchases(data);
await purchase.save();
res.status(201).json({ message: "Stock purchase created", purchase });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟠 **PATCH: Perbarui Pembelian Stok**
const updateStockPurchase = [
param("id").isMongoId().withMessage("Invalid purchase ID"),
body("amount").optional().isNumeric().withMessage("Amount must be a number"),
body("total_price").optional().isNumeric().withMessage("Total price must be a number"),
body("supplier_id").optional().isMongoId().withMessage("Invalid supplier ID"),
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = matchedData(req);
if (data.supplier_id) {
const supplierExists = await suppliers.findById(data.supplier_id);
if (!supplierExists) return next(createHttpError(404, "Supplier not found"));
}
const purchase = await purchases.findByIdAndUpdate(id, data, { new: true });
if (!purchase) return next(createHttpError(404, "Stock purchase not found"));
res.json({ message: "Stock purchase updated", purchase });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔴 **DELETE: Hapus Pembelian Stok**
const deleteStockPurchase = [
param("id").isMongoId().withMessage("Invalid purchase ID"),
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const purchase = await purchases.findByIdAndDelete(id);
if (!purchase) return next(createHttpError(404, "Stock purchase not found"));
res.json({ message: "Stock purchase deleted" });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔵 **GET: Ambil Satu Pembelian Stok Berdasarkan ID**
const getStockPurchase = [
param("id").isMongoId().withMessage("Invalid purchase ID"),
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const purchase = await purchases.findById(id).populate("product_id supplier_id user_id");
if (!purchase) return next(createHttpError(404, "Stock purchase not found"));
res.json(purchase);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟡 **GET: Ambil Semua Pembelian Stok (Histori)**
const getStockPurchases = async (req: Request, res: Response, next: NextFunction) => {
try {
const allPurchases = await purchases.find().populate("product_id supplier_id user_id");
res.json(allPurchases);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
};
export default {
createStockPurchase,
updateStockPurchase,
deleteStockPurchase,
getStockPurchase,
getStockPurchases
};

View File

@ -0,0 +1,101 @@
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
};

View File

@ -0,0 +1,108 @@
// controllers/transactionsController.ts
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 { NextFunction, Request, Response } from "express";
import { ITransaction } from "../../types/db-model";
// 🟢 **POST: Tambah Transaksi Baru**
const createTransaction = [
body("amount").notEmpty().isNumeric().withMessage("Amount must be a number"),
body("total_price").notEmpty().isNumeric().withMessage("Total price must be a number"),
body("user_id").notEmpty().isInt().withMessage("Invalid user ID"), // isMongoId() diganti isInt()
body("product_id").notEmpty().isInt().withMessage("Invalid product ID"), // isMongoId() diganti isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = matchedData<ITransaction>(req);
// Cek apakah produk ada
const productExists = await productsRepository.getProductById(data.product_id);
if (!productExists) return next(createHttpError(404, "Product not found"));
// Simpan data transaksi
const transaction = await transactionsRepository.createTransaction(data);
res.status(201).json({ message: "Transaction created", transaction });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟠 **PATCH: Perbarui Transaksi**
const updateTransaction = [
param("id").isInt().withMessage("Invalid transaction ID"), // isMongoId() diganti isInt()
body("amount").optional().isNumeric().withMessage("Amount must be a number"),
body("total_price").optional().isNumeric().withMessage("Total price must be a number"),
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = matchedData<ITransaction>(req);
const transaction = await transactionsRepository.updateTransactionById(Number(id), data);
if (!transaction) return next(createHttpError(404, "Transaction not found"));
res.json({ message: "Transaction updated", transaction });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔴 **DELETE: Hapus Transaksi**
const deleteTransaction = [
param("id").isInt().withMessage("Invalid transaction ID"), // isMongoId() diganti isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const transaction = await transactionsRepository.deleteTransactionById(Number(id));
if (!transaction) return next(createHttpError(404, "Transaction not found"));
res.json({ message: "Transaction deleted" });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔵 **GET: Ambil Satu Transaksi Berdasarkan ID**
const getTransaction = [
param("id").isInt().withMessage("Invalid transaction ID"), // isMongoId() diganti isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const transaction = await transactionsRepository.getTransactionById(Number(id));
if (!transaction) return next(createHttpError(404, "Transaction not found"));
res.json(transaction);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟡 **GET: Ambil Semua Transaksi**
const getTransactions = async (req: Request, res: Response, next: NextFunction) => {
try {
const allTransactions = await transactionsRepository.getAllTransactions();
res.json(allTransactions);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
};
export default {
createTransaction,
updateTransaction,
deleteTransaction,
getTransaction,
getTransactions
};

View File

@ -0,0 +1,103 @@
// controllers/userFilesController.ts
import { body, param, matchedData } from "express-validator";
import createHttpError from "http-errors";
import validate from "../../middleware/expressValidatorErrorHandler"; // Validation middleware
import * as userFilesRepository from "../../repository/user-files"; // Repository untuk User Files
import { NextFunction, Request, Response } from "express";
import { IUserFile } from "../../types/db-model";
// 🟢 **POST: Tambah User File Baru**
const createUserFile = [
body("file_name").notEmpty().withMessage("File name is required"),
body("saved_file_name").notEmpty().withMessage("Saved file name is required"),
body("is_converted").optional().isBoolean().withMessage("is_converted must be a boolean"),
body("user_id").notEmpty().isInt().withMessage("Invalid user ID"), // ganti isMongoId() ke isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = matchedData<IUserFile>(req);
const userFile = await userFilesRepository.createUserFile(data);
res.status(201).json({ message: "User file created", userFile });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟠 **PATCH: Perbarui File User**
const updateUserFile = [
param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt()
body("file_name").optional().isString(),
body("saved_file_name").optional().isString(),
body("is_converted").optional().isBoolean(),
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = matchedData<IUserFile>(req);
const userFile = await userFilesRepository.updateUserFileById(Number(id), data);
if (!userFile) return next(createHttpError(404, "User file not found"));
res.json({ message: "User file updated", userFile });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔴 **DELETE: Hapus File User**
const deleteUserFile = [
param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const userFile = await userFilesRepository.deleteUserFileById(Number(id));
if (!userFile) return next(createHttpError(404, "User file not found"));
res.json({ message: "User file deleted" });
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🔵 **GET: Ambil Satu File User Berdasarkan ID**
const getUserFile = [
param("id").isInt().withMessage("Invalid file ID"), // ganti isMongoId() ke isInt()
validate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const userFile = await userFilesRepository.getUserFileById(Number(id));
if (!userFile) return next(createHttpError(404, "User file not found"));
res.json(userFile);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
}
];
// 🟡 **GET: Ambil Semua File User**
const getUserFiles = async (req: Request, res: Response, next: NextFunction) => {
try {
const allUserFiles = await userFilesRepository.getAllUserFiles();
res.json(allUserFiles);
} catch (err) {
next(createHttpError(500, "Internal Server Error", { cause: err }));
}
};
export default {
createUserFile,
updateUserFile,
deleteUserFile,
getUserFile,
getUserFiles
};

38
src/database/MySQL.ts Normal file
View File

@ -0,0 +1,38 @@
import knex from 'knex';
import dotenv from 'dotenv';
dotenv.config();
const {
DB_USERNAME,
DB_PASSWORD,
DB_DATABASE,
DB_HOST = 'localhost',
} = process.env;
if (!DB_USERNAME || !DB_DATABASE) {
throw new Error("Missing required environment variables: DB_USERNAME, DB_PASSWORD, DB_DATABASE");
}
const db = knex({
client: 'mysql2',
connection: {
host: DB_HOST,
user: DB_USERNAME,
password: DB_PASSWORD,
database: DB_DATABASE,
},
pool: { min: 0, max: 10 },
});
async function connectDB() {
try {
await db.raw('SELECT 1');
console.log('✅ Connected to MySQL using Knex');
} catch (error) {
console.error('❌ MySQL Connection Error:', error);
process.exit(1);
}
}
export { db };
export default connectDB;

97
src/database/models.ts Normal file
View File

@ -0,0 +1,97 @@
// database/migrations/2025XXXX_create_all_tables.js
import type { Knex } from 'knex';
export async function up(knex: Knex) {
// Users
await knex.schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('email').notNullable().unique();
table.string('username').unique();
table.string('password').notNullable();
table.string('name');
table.string('profile_picture');
table.boolean('is_verified').defaultTo(false);
table.string('refresh_token').defaultTo('');
table.timestamps(true, true);
});
// Product Categories
await knex.schema.createTable('product_categories', (table) => {
table.increments('id').primary();
table.string('category_name').notNullable();
table.string('description');
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
});
// Products
await knex.schema.createTable('products', (table) => {
table.increments('id').primary();
table.string('product_name').notNullable();
table.integer('stock').notNullable();
table.float('price').notNullable();
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
table.integer('product_category_id').unsigned().references('id').inTable('product_categories').onDelete('SET NULL');
});
// Suppliers
await knex.schema.createTable('suppliers', (table) => {
table.increments('id').primary();
table.string('supplier_name').notNullable();
table.string('contact');
table.string('address');
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
});
// Purchases
await knex.schema.createTable('purchases', (table) => {
table.increments('id').primary();
table.date('buying_date').defaultTo(knex.fn.now()).notNullable();
table.integer('amount').notNullable();
table.float('total_price').notNullable();
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
table.integer('product_id').unsigned().references('id').inTable('products').onDelete('CASCADE');
table.integer('supplier_id').unsigned().references('id').inTable('suppliers').onDelete('CASCADE');
});
// Transactions
await knex.schema.createTable('transactions', (table) => {
table.increments('id').primary();
table.date('transaction_date').defaultTo(knex.fn.now()).notNullable();
table.integer('amount').notNullable();
table.float('total_price').notNullable();
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
table.integer('product_id').unsigned().references('id').inTable('products').onDelete('CASCADE');
});
// Predictions
await knex.schema.createTable('predictions', (table) => {
table.increments('id').primary();
table.integer('stock_sold').notNullable();
table.integer('stock_predicted').notNullable();
table.string('type').notNullable();
table.float('accuracy');
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
table.integer('product_id').unsigned().references('id').inTable('products').onDelete('CASCADE');
});
// User Files
await knex.schema.createTable('user_files', (table) => {
table.increments('id').primary();
table.string('file_name').notNullable();
table.string('saved_file_name').notNullable().unique();
table.boolean('is_converted').defaultTo(false);
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE');
});
}
export async function down(knex: Knex) {
await knex.schema
.dropTableIfExists('user_files')
.dropTableIfExists('predictions')
.dropTableIfExists('transactions')
.dropTableIfExists('purchases')
.dropTableIfExists('suppliers')
.dropTableIfExists('products')
.dropTableIfExists('product_categories')
.dropTableIfExists('users');
}

View File

@ -0,0 +1,22 @@
import { RequestHandler } from "express";
import createHttpError from "http-errors";
import jwt from 'jsonwebtoken';
import { TAccessToken } from "../types/jwt";
const authenticate: RequestHandler = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return next(createHttpError(401, "Access token required"));
}
const token = authHeader.split(" ")[1];
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) return next(createHttpError(403, "Invalid or expired access token"));
req.user = decoded as TAccessToken;
next();
});
};
export default authenticate;

View File

@ -0,0 +1,38 @@
import { ErrorRequestHandler } from "express";
import { TAPIResponse } from "../../types/core/http";
import dotenv from 'dotenv'
dotenv.config()
export const respondWithError: ErrorRequestHandler = (err, req, res, _next) => {
res.status(err.status || 500);
if (
req.xhr ||
req.headers["content-type"] === "application/json" ||
req.headers.accept?.includes("application/json")
) {
const errorResponse: TAPIResponse<any[]> = { success: false }
if ([
'Access token required',
'Invalid or expired access token',
'No refresh token provided',
'Invalid refresh token'
].includes(err.message)) {
errorResponse.message = err.message
} else if (err.message === 'Validation Error') {
errorResponse.message = err.message
errorResponse.error = err.cause
} else {
errorResponse.message = err.message || "Internal Server Error"
errorResponse.error = err
}
res.json(errorResponse);
return
}
res.locals.message = `${err.message}`;
res.locals.error = process.env.NODE_ENV === 'development' ? err : {};
res.render('error');
}

View File

@ -0,0 +1,13 @@
import { RequestHandler } from 'express';
import { validationResult } from 'express-validator';
import createHttpError from 'http-errors';
const expressValidatorErrorHandler: RequestHandler = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(createHttpError(400, "Validation Error", { cause: errors.array() }));
}
next();
};
export default expressValidatorErrorHandler

View File

@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}

View File

@ -0,0 +1,64 @@
// 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<IProduct>) => {
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");
}
};

View File

@ -0,0 +1,55 @@
// repository/transactionsRepository.ts
import { db } from '../database/MySQL';
import { ITransaction } from '../types/db-model';
export const createTransaction = async (data: ITransaction) => {
try {
const [transaction] = await db('transactions').insert(data).returning('*');
return transaction;
} catch (err) {
throw new Error('Error creating transaction');
}
};
export const getTransactionById = async (id: number) => {
try {
const transaction = await db('transactions')
.where({ id })
.join('products', 'transactions.product_id', '=', 'products.id')
.join('users', 'transactions.user_id', '=', 'users.id')
.select('transactions.*', 'products.product_name', 'users.username');
return transaction[0];
} catch (err) {
throw new Error('Error fetching transaction');
}
};
export const updateTransactionById = async (id: number, data: ITransaction) => {
try {
const [updatedTransaction] = await db('transactions').where({ id }).update(data).returning('*');
return updatedTransaction;
} catch (err) {
throw new Error('Error updating transaction');
}
};
export const deleteTransactionById = async (id: number) => {
try {
const transaction = await db('transactions').where({ id }).del();
return transaction;
} catch (err) {
throw new Error('Error deleting transaction');
}
};
export const getAllTransactions = async () => {
try {
return await db('transactions')
.join('products', 'transactions.product_id', '=', 'products.id')
.join('users', 'transactions.user_id', '=', 'users.id')
.select('transactions.*', 'products.product_name', 'users.username');
} catch (err) {
throw new Error('Error fetching all transactions');
}
};

View File

@ -0,0 +1,48 @@
// repository/userFilesRepository.ts
import { db } from '../database/MySQL';
import { IUserFile } from '../types/db-model';
export const createUserFile = async (data: IUserFile) => {
try {
const [userFile] = await db('user_files').insert(data).returning('*');
return userFile;
} catch (err) {
throw new Error('Error creating user file');
}
};
export const getUserFileById = async (id: number) => {
try {
const userFile = await db('user_files').where({ id }).first();
return userFile;
} catch (err) {
throw new Error('Error fetching user file');
}
};
export const updateUserFileById = async (id: number, data: IUserFile) => {
try {
const [updatedUserFile] = await db('user_files').where({ id }).update(data).returning('*');
return updatedUserFile;
} catch (err) {
throw new Error('Error updating user file');
}
};
export const deleteUserFileById = async (id: number) => {
try {
const userFile = await db('user_files').where({ id }).del();
return userFile;
} catch (err) {
throw new Error('Error deleting user file');
}
};
export const getAllUserFiles = async () => {
try {
return await db('user_files');
} catch (err) {
throw new Error('Error fetching all user files');
}
};

View File

@ -0,0 +1,28 @@
import { db } from "../database/MySQL"
import { IUserTable } from "../types/db-model"
import { TCreateUserRequest } from "../types/request/users-req";
export const findUserById = (id: number) => {
return db<IUserTable>('users').where({ id });
};
export const findUserByEmail = (email: string) => {
return db<IUserTable>('users').where({ email })
}
export const findUserByRefreshToken = (refresh_token: string) => {
return db<IUserTable>('users').where({ refresh_token });
}
export const updateUserByCondition = (condition: Partial<IUserTable>, data: Partial<IUserTable>) => {
return db<IUserTable>('users').where(condition).update(data)
}
export const createUser = async ({ email, name, password }: TCreateUserRequest) => {
const [id] = await db<IUserTable>('users').insert({
email,
name,
password,
});
return id
};

15
src/routes/api/auth.ts Normal file
View File

@ -0,0 +1,15 @@
import express from 'express'
var router = express.Router();
import authRoute from '../../controller/api/authController';
router.post('/register', authRoute.register);
router.post('/login', authRoute.login);
router.post('/forgot-password', authRoute.forgotPasswordSendEmail);
router.get('/forgot-password/:token', authRoute.forgotPasswordVerifyToken);
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);
export default router

80
src/routes/api/index.ts Normal file
View File

@ -0,0 +1,80 @@
import express from 'express'
var router = express.Router();
import authEndpoint from './auth'
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.');
});
router.use('/auth', authEndpoint)
router.use('/is-authenticated', authenticate)
router.get('/is-authenticated', (req, res) => {
res.status(200).json({
success: true,
})
})
// ----- 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);
export default router;

11
src/server.ts Normal file
View File

@ -0,0 +1,11 @@
// src/server.ts
import app from './app';
import http from 'http';
const PORT = process.env.PORT || 3000;
const server = http.createServer(app);
server.listen(PORT, () => {
console.log(`🚀 Server running at http://localhost:${PORT}`);
});

View File

@ -0,0 +1,9 @@
import { findUserByEmail, findUserById, findUserByRefreshToken, updateUserByCondition } from "../repository/usersRepository";
import { IUserTable } from "../types/db-model";
export const getUserById = (id: number) => findUserById(id).first()
export const getUserByEmail = (email: string) => findUserByEmail(email).first()
export const getUserByRefreshToken = (refresh_token: string) =>
findUserByRefreshToken(refresh_token).first()
export const activateUser = (email: string) => findUserByEmail(email).update({ is_verified: true })
export const updateUserById = (id: number, data: Partial<IUserTable>) => updateUserByCondition({ id }, data)

25
src/types/core/global/env.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
HOST: string;
PORT: string;
ALLOWED_ORIGIN: string;
DB_CONNECTION: 'mysql' | string;
DB_HOST: string;
DB_PORT: string;
DB_DATABASE: string;
DB_USERNAME: string;
DB_PASSWORD: string;
ATLAS_USERNAME: string;
ATLAS_PASSWORD: string;
ATLAS_DBNAME: string;
JWT_SECRET: string;
JWT_REFRESH_SECRET: string;
EMAIL_USER: string;
EMAIL_PASS: string;
}
}

9
src/types/core/global/http.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import { IUser } from "../../db-model";
import { TAccessToken } from "../../jwt";
import { TAPIResponse, TPaginatedResponse } from "../http";
declare module "express-serve-static-core" {
interface Request {
user?: TAccessToken;
}
}

15
src/types/core/http.ts Normal file
View File

@ -0,0 +1,15 @@
export type TAPIResponse<T = Record<string, any>> = {
success: boolean;
message?: string;
error?: Record<string, any>,
data?: T;
};
export type TPaginatedResponse<T = Record<string, any>> = TAPIResponse<T> & {
pagination?: {
total: number;
page: number;
pageSize: number;
totalPages: number;
}
};

71
src/types/db-model.ts Normal file
View File

@ -0,0 +1,71 @@
export interface IUserTable {
id: number;
name: string;
email: string;
password: string;
is_verified: boolean;
refresh_token: string | null;
created_at: Date;
updated_at: Date;
}
export interface IProductCategoryTable {
user_id: number;
category_name: string;
id: number;
description: string;
}
export interface IProductTable {
id: number;
product_name: string;
stock: number;
price: number;
user_id: number;
product_category_id: number;
}
export interface ISupplierTable {
id: number;
supplier_name: string;
contact: string;
address: string;
user_id: number;
}
export interface IPurchaseTable {
id: number;
buying_date: Date;
amount: number;
total_price: number;
user_id: number;
product_id: number;
supplier_id: number;
}
export interface ITransactionTable {
id: number;
transaction_date: Date;
amount: number;
total_price: number;
user_id: number;
product_id: number;
}
export interface IPredictionTable {
id: number;
stock_sold: number;
stock_predicted: number;
type: string;
accuracy: number;
user_id: number;
product_id: number;
}
export interface IUserFileTable {
id: number;
file_name: string;
saved_file_name: string;
is_converted: boolean;
user_id: number;
}

7
src/types/jwt.ts Normal file
View File

@ -0,0 +1,7 @@
export type TRefreshToken = {
id: number;
}
export type TAccessToken = TRefreshToken & {
email: string;
}

View File

@ -0,0 +1,6 @@
export type TCreateUserRequest = {
name: string
email: string
password: string
password_confirmation?: string
}

View File

@ -0,0 +1,4 @@
import { TAPIResponse } from "../core/http";
import { IUserTable } from "../db-model";
export type TUserDetailResponse = TAPIResponse<IUserTable>

View File

@ -0,0 +1,26 @@
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv'
import { TAccessToken, TRefreshToken } from '../../types/jwt';
import { IUserTable } from '../../types/db-model';
dotenv.config()
const generateAccessToken = (user: IUserTable) => {
return jwt.sign(
{ id: user.id, email: user.email } as TAccessToken,
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
};
const generateRefreshToken = (user: IUserTable) => {
return jwt.sign(
{ id: user.id } as TRefreshToken,
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "30d" }
);
};
export {
generateAccessToken,
generateRefreshToken
}

View File

@ -0,0 +1,7 @@
import dotenv from 'dotenv'
dotenv.config({ path: '.env' })
import path from 'path';
export function root_path(pathStr: string) {
return path.resolve(process.cwd(), pathStr);
}

8
src/views/error.ejs Normal file
View File

@ -0,0 +1,8 @@
<h1>
<%= error.status || 500 %>
</h1>
<h2>
<%= message %>
</h2>
<pre><%= error.cause ? JSON.stringify(error.cause) : 'Unknown cause' %></pre>
<pre><%= error.stack ? error.stack : 'No stack trace available' %></pre>

11
src/views/index.ejs Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
</body>
</html>

113
tsconfig.json Normal file
View File

@ -0,0 +1,113 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "libReplacement": true, /* Enable lib replacement. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "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": ["./types", "../node_modules/@types"], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* 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. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./bin/build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}