diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..8ed5673 --- /dev/null +++ b/config/index.js @@ -0,0 +1,204 @@ +require("dotenv").config(); +const sql = require("mssql"); + +const isProduction = process.env.NODE_ENV === "production"; + +// Config SQL Server +const config = { + user: process.env.SQL_USERNAME, + password: process.env.SQL_PASSWORD, + database: process.env.SQL_DATABASE, + server: process.env.SQL_HOST, + port: parseInt(process.env.SQL_PORT, 10), + options: { + encrypt: false, // true kalau Azure + trustServerCertificate: true, + }, +}; + +// Buat pool global +const poolPromise = new sql.ConnectionPool(config) + .connect() + .then(pool => { + console.log("✅ Koneksi SQL Server berhasil"); + return pool; + }) + .catch(err => { + console.error("❌ Gagal koneksi SQL Server:", err); + process.exit(1); + }); + +/** + * Wrapper query (auto konversi $1 → @p1) + */ +async function query(text, params = []) { + const pool = await poolPromise; + const request = pool.request(); + + params.forEach((param, i) => { + request.input(`p${i + 1}`, param); + }); + + // Ubah $1, $2 jadi @p1, @p2 + const sqlText = text.replace(/\$(\d+)/g, (_, num) => `@p${num}`); + + console.log(sqlText, params); + return request.query(sqlText); +} + +/** + * Build filter query + */ +function buildFilterQuery(filterQuery = [], fixedParams = []) { + let whereConditions = []; + let queryParams = [...fixedParams]; + + filterQuery.forEach((f) => { + if (f.param === undefined || f.param === null || f.param === "") return; + + switch (f.type) { + case "string": + queryParams.push(`%${f.param}%`); + whereConditions.push(`${f.column} LIKE $${queryParams.length} COLLATE SQL_Latin1_General_CP1_CI_AS`); + break; + + case "number": + queryParams.push(f.param); + whereConditions.push(`${f.column} = $${queryParams.length}`); + break; + + case "boolean": + queryParams.push(f.param ? 1 : 0); + whereConditions.push(`${f.column} = $${queryParams.length}`); + break; + } + }); + + return { whereConditions, queryParams }; +} + +/** + * Build OR ILIKE (SQL Server pakai LIKE + COLLATE) + */ +function buildStringOrIlike(columnParam, criteria, fixedParams = []) { + if (!criteria) return { whereClause: "", whereParam: fixedParams }; + + let orStringConditions = []; + let queryParams = [...fixedParams]; + + columnParam.forEach((column) => { + if (!column) return; + queryParams.push(`%${criteria}%`); + orStringConditions.push(`${column} LIKE $${queryParams.length} COLLATE SQL_Latin1_General_CP1_CI_AS`); + }); + + const whereClause = orStringConditions.length + ? `AND (${orStringConditions.join(" OR ")})` + : ""; + + return { whereOrConditions: whereClause, whereParam: queryParams }; +} + +/** + * Build dynamic UPDATE + */ +function buildDynamicUpdate(table, data, where) { + const setParts = []; + const values = []; + let index = 1; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + setParts.push(`${key} = $${index++}`); + values.push(value); + } + } + + if (setParts.length === 0) { + throw new Error("Tidak ada kolom untuk diupdate"); + } + + // updated_at otomatis pakai GETDATE() + setParts.push(`updated_at = GETDATE()`); + + const whereParts = []; + for (const [key, value] of Object.entries(where)) { + whereParts.push(`${key} = $${index++}`); + values.push(value); + } + + const query = ` + UPDATE ${table} + SET ${setParts.join(", ")} + WHERE ${whereParts.join(" AND ")} + `; + + return { query, values }; +} + +/** + * Build dynamic INSERT + */ +function buildDynamicInsert(table, data) { + const columns = []; + const placeholders = []; + const values = []; + let index = 1; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + columns.push(key); + placeholders.push(`$${index++}`); + values.push(value); + } + } + + if (columns.length === 0) { + throw new Error("Tidak ada kolom untuk diinsert"); + } + + // created_at & updated_at otomatis + columns.push("created_at", "updated_at"); + placeholders.push("GETDATE()", "GETDATE()"); + + const query = ` + INSERT INTO ${table} (${columns.join(", ")}) + VALUES (${placeholders.join(", ")}); + SELECT SCOPE_IDENTITY() as inserted_id; + `; + + return { query, values }; +} + +/** + * Generate kode otomatis + */ +async function generateKode(prefix, tableName, columnName) { + const pool = await poolPromise; + const result = await pool.request() + .input("prefix", sql.VarChar, prefix + "%") + .query(` + SELECT TOP 1 ${columnName} as kode + FROM ${tableName} + WHERE ${columnName} LIKE @prefix + ORDER BY ${columnName} DESC + `); + + let nextNumber = 1; + if (result.recordset.length > 0) { + const lastKode = result.recordset[0].kode; + const lastNumber = parseInt(lastKode.replace(prefix, ""), 10); + nextNumber = lastNumber + 1; + } + + return prefix + String(nextNumber).padStart(3, "0"); +} + +module.exports = { + query, + buildFilterQuery, + buildStringOrIlike, + buildDynamicInsert, + buildDynamicUpdate, + generateKode, +}; diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js new file mode 100644 index 0000000..464b3cd --- /dev/null +++ b/controllers/auth.controller.js @@ -0,0 +1,26 @@ +const authService = require("../services/auth.service"); + +const loginUser = async (req, res) => { + const { username, password, role, tenant } = req.body; + const { token, refreshToken, user } = await authService.login( + username, + password, + tenant + ); + + res.header("auth-token", token); + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: process.env.NODE_ENV === "development" ? true : "none", + secure: process.env.NODE_ENV === "development" ? false : true, + }); + res.status(200).json({ + token, + refreshToken, + user, + }); +}; + +module.exports = { + loginUser, +}; diff --git a/controllers/users.controller.js b/controllers/users.controller.js new file mode 100644 index 0000000..ae9548e --- /dev/null +++ b/controllers/users.controller.js @@ -0,0 +1,172 @@ +const userService = require("../services/user.service"); +const { ErrorHandler } = require("../helpers/error"); +const { hashPassword } = require("../helpers/hashPassword"); +const { setResponse, setPaging, setResponsePaging } = require("../helpers/utils"); +const Joi = require("joi"); + +// Definisikan skema validasi +const validateTerm = Joi.object({ + user_fullname: Joi.string().max(255).required(), + user_name: Joi.string().max(255).required(), + user_email: Joi.string().max(255).email().allow(null), + user_password: Joi.string().max(255).required(), + role_id: Joi.number().integer().allow(null), + is_active: Joi.boolean().required() +}); + +const getAllUsers = async (req, res) => { + + const { + page = 1, + limit = 10, + fullname: userFullname, + username: userName, + is_active: isActive, + criteria, + tenantID, + } = req.query + + const offset = (page - 1) * limit; + + const filterQuery = { + fixed: { + limit, offset, tenantID + }, + filterQuery: [ + { + type: 'string', + column: 'user_fullname', + param: userFullname + }, + { + type: 'string', + column: 'user_name', + param: userName + }, + { + type: 'number', + column: 'is_active', + param: isActive + } + ], + filterCriteria: + { + criteria, + column: [ + 'user_fullname', 'user_name' + ] + } + } + + const results = await userService.getAllUsers(filterQuery) + const response = await setResponsePaging(results.data, results.total, parseInt(limit), parseInt(page)) + + res.status(response.statusCode).json(response) +}; + +const getAllStatusUsers = async (req, res) => { + + const results = await userService.getAllStatusUsers(); + const response = await setResponse(results) + + res.status(response.statusCode).json(response); +}; + +const createUser = async (req, res) => { + + // Lakukan validasi + const { error } = validateTerm.validate(req.body, { stripUnknown: true }); + if (error) { + const response = await setResponse([], error.details[0].message, 400) + return res.status(response.statusCode).json(response); + } + + const results = await userService.createUser({ + userFullname: req.body.user_fullname, + userName: req.body.user_name, + userEmail: req.body.user_email, + userPassword: req.body.user_password, + roleId: req.body.role_id, + isActive: req.body.is_active, // default 1 jika tidak dikirim + userID: req.body.userID, + tenantID: req.body.tenantID + }); + + const response = await setResponse(results); + + res.status(response.statusCode).json(response); +}; + +const getUserById = async (req, res) => { + const { id } = req.params; + + const results = await userService.getUserById(id); + const response = await setResponse(results) + + res.status(response.statusCode).json(response); +}; + +const getUserProfile = async (req, res) => { + const { id } = req.user; + + const results = await userService.getUserById(id); + const response = await setResponse(results) + + res.status(response.statusCode).json(response); +}; + +const updateUser = async (req, res) => { + + const { id } = req.params; + + // Lakukan validasi + const { error } = validateTerm.validate(req.body, { stripUnknown: true }); + if (error) { + const response = await setResponse([], error.details[0].message, 400) + return res.status(response.statusCode).json(response); + } + + const results = await userService.updateUser({ + userFullname: req.body.user_fullname, + userName: req.body.user_name, + userEmail: req.body.user_email, + userPassword: req.body.user_password, + roleId: req.body.role_id, + isActive: req.body.is_active, // default 1 jika tidak dikirim + userID: req.body.userID, + tenantID: req.body.tenantID, + id + }); + + const response = await setResponse(results); + + res.status(response.statusCode).json(response); +}; + +const deleteUser = async (req, res) => { + const { id } = req.params; + const userID = req.userID + + const results = await userService.deleteUser(id, userID); + const response = await setResponse(results) + + res.status(response.statusCode).json(response); +}; + +const getAllRoles = async (req, res) => { + const results = await userService.getAllRoles(req.body.tenantID); + const response = await setResponse(results) + + res.status(response.statusCode).json(response); +}; + +module.exports = { + getAllUsers, + createUser, + getUserById, + updateUser, + deleteUser, + getUserProfile, + getAllRoles, + getAllStatusUsers +}; diff --git a/db/user.db.js b/db/user.db.js new file mode 100644 index 0000000..46471bc --- /dev/null +++ b/db/user.db.js @@ -0,0 +1,142 @@ +const pool = require("../config"); + +const getAllUsersDb = async (param) => { + // limit & offset masuk fixed param + let fixedParams = [param.fixed.limit, param.fixed.offset, param.fixed.tenantID]; + + const { whereOrConditions, whereParam } = pool.buildStringOrIlike( + param.filterCriteria.column, + param.filterCriteria.criteria, + fixedParams + ); + const { whereConditions, queryParams } = pool.buildFilterQuery(param.filterQuery, whereParam);npm + + const query = ` + SELECT mut.*, mr.role_name, COUNT(*) OVER() AS total + FROM m_users mut + LEFT JOIN system.role_tenant mr ON mr.role_id = mut.role_id + WHERE mut.deleted_at IS NULL AND mut.is_sa != 1 AND mut.tenant_id = $3 + ${whereConditions.length > 0 ? ` AND ${whereConditions.join(" AND ")}` : ""} + ${whereOrConditions ? whereOrConditions : ""} + ORDER BY mut.user_id + OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY + `; + + const result = await pool.query(query, queryParams); + const rows = result.recordset; + + const total = rows.length > 0 ? parseInt(rows[0].total, 10) : 0; + return { data: rows, total }; +}; + +const createUserDb = async (param) => { + const insertData = { + tenant_id: param.tenantID, + user_fullname: param.userFullname, + user_name: param.userName, + user_email: param.userEmail ?? null, + user_password: param.userPassword, + role_id: param.roleId ?? null, + is_active: param.isActive ? 1 : 0, + created_by: param.userID, + updated_by: param.userID, + }; + + const { query, values } = pool.buildDynamicInsert("m_users", insertData); + + const result = await pool.query(query, values); + return result.recordset[0]; +}; + +const getUserByIdDb = async (id) => { + const query = ` + SELECT mut.* + FROM m_users mut + WHERE mut.user_id = $1 + `; + const result = await pool.query(query, [id]); + return result.recordset[0]; +}; + +const getUserByUsernameDb = async (username) => { + const query = ` + SELECT mut.* + FROM m_users mut + WHERE LOWER(mut.username) = LOWER($1) + `; + const result = await pool.query(query, [username]); + return result.recordset[0]; +}; + +const getUserByUserEmailDb = async (userEmail) => { + const query = ` + SELECT mut.* + FROM m_users mut + WHERE LOWER(mut.user_email) = LOWER($1) + `; + const result = await pool.query(query, [userEmail]); + return result.recordset[0]; +}; + +const updateUserDb = async (param) => { + const updateData = { + tenant_id: param.tenantID, + user_fullname: param.userFullname, + user_name: param.userName, + user_email: param.userEmail ?? null, + user_password: param.userPassword, + role_id: param.roleId ?? null, + is_active: param.isActive ? 1 : 0, + updated_by: param.userID, + }; + + const whereData = { user_id: param.id }; + + const { query, values } = pool.buildDynamicUpdate("m_users", updateData, whereData); + + const result = await pool.query(query, values); + return result.recordset[0]; +}; + +const deleteUserDb = async (id, userID) => { + const query = ` + UPDATE m_users + SET deleted_at = GETDATE(), deleted_by = $1 + WHERE user_id = $2; + + SELECT * FROM m_users WHERE user_id = $2 + `; + const result = await pool.query(query, [userID, id]); + return result.recordset[0]; +}; + +const changeUserPasswordDb = async (hashedPassword, userEmail, tenantId) => { + const query = ` + UPDATE m_users + SET user_password = $1 + WHERE user_email = $2 AND tenant_id = $3 + `; + return pool.query(query, [hashedPassword, userEmail, tenantId]); +}; + +const getAllRoleDb = async (tenantId) => { + const query = ` + SELECT * + FROM system.role_tenant + WHERE deleted_at IS NULL AND tenant_id = $1 + `; + const result = await pool.query(query, [tenantId]); + return result.recordset; +}; + +module.exports = { + getAllUsersDb, + getUserByIdDb, + getUserByUserEmailDb, + updateUserDb, + createUserDb, + deleteUserDb, + getUserByUsernameDb, + changeUserPasswordDb, + getAllRoleDb, +};