Files
ManagerAccount/backend/server.js
2026-05-06 11:14:10 +07:00

4392 lines
173 KiB
JavaScript

// Backend Server for AccManager
// Express.js + mssql
const express = require('express');
const sql = require('mssql');
const cors = require('cors');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const nodemailer = require('nodemailer');
const multer = require('multer');
const XLSX = require('xlsx');
const dotenv = require('dotenv');
const app = express();
dotenv.config();
function envBool(name, defaultValue) {
const value = process.env[name];
if (value === undefined) {
return defaultValue;
}
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
const DB_SERVER = process.env.DB_SERVER || '172.20.235.176';
const DB_USER = process.env.DB_USER || 'sa';
const DB_PASSWORD = process.env.DB_PASSWORD || 'robotics@2022';
const DB_NAME = process.env.DB_NAME || 'AccManager';
const DB_ENCRYPT = envBool('DB_ENCRYPT', false);
const DB_TRUST_CERTIFICATE = envBool('DB_TRUST_CERTIFICATE', true);
const DB_CONNECT_TIMEOUT = Number(process.env.DB_CONNECT_TIMEOUT || 30000);
const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS || 12);
const PASSWORD_VIEW_SECRET = process.env.PASSWORD_VIEW_SECRET || 'change-this-password-view-secret';
const PASSWORD_VIEW_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest();
const PASSWORD_VIEW_PREFIX = 'enc:v1';
const PORT = process.env.PORT || 3000;
const APP_BASE_URL = String(process.env.APP_BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
const SMTP_HOST = process.env.SMTP_HOST || '';
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_SECURE = envBool('SMTP_SECURE', false);
const SMTP_USER = process.env.SMTP_USER || '';
const SMTP_PASS = process.env.SMTP_PASS || '';
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
let mailTransporter;
function isBcryptHash(value) {
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value);
}
async function hashPassword(plainPassword) {
return bcrypt.hash(String(plainPassword), BCRYPT_ROUNDS);
}
async function verifyPassword(plainPassword, storedPassword) {
if (isBcryptHash(storedPassword)) {
return bcrypt.compare(String(plainPassword), storedPassword);
}
// Legacy fallback for old plain-text records.
return String(plainPassword) === String(storedPassword || '');
}
function encryptPasswordForView(plainPassword) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(String(plainPassword), 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
return `${PASSWORD_VIEW_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
function decryptPasswordForView(payload) {
try {
if (typeof payload !== 'string' || !payload.startsWith(`${PASSWORD_VIEW_PREFIX}:`)) {
return null;
}
const parts = payload.split(':');
if (parts.length !== 5) {
return null;
}
const iv = Buffer.from(parts[2], 'base64');
const tag = Buffer.from(parts[3], 'base64');
const encrypted = Buffer.from(parts[4], 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return plain.toString('utf8');
} catch (err) {
return null;
}
}
function hashVerificationToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function generateEmailVerificationToken() {
const token = crypto.randomBytes(32).toString('hex');
return {
token,
tokenHash: hashVerificationToken(token)
};
}
function getEmailVerificationUrl(token) {
return `${APP_BASE_URL}/pages/verify-email.html?token=${encodeURIComponent(token)}`;
}
function getPasswordResetUrl(token) {
return `${APP_BASE_URL}/pages/login.html?mode=reset-password&token=${encodeURIComponent(token)}`;
}
function canSendEmails() {
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS);
}
function getMailTransporter() {
if (!mailTransporter) {
mailTransporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: {
user: SMTP_USER,
pass: SMTP_PASS
}
});
}
return mailTransporter;
}
async function sendVerificationEmail({ email, username, token }) {
const verifyUrl = getEmailVerificationUrl(token);
if (!canSendEmails()) {
console.warn(`SMTP is not configured. Verification URL for ${email}: ${verifyUrl}`);
return {
sent: false,
previewUrl: verifyUrl,
reason: 'SMTP is not configured'
};
}
try {
const transporter = getMailTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: email,
subject: 'AccManager - Confirm your email',
text: `Hello ${username || 'there'},\n\nPlease confirm your email by opening this link:\n${verifyUrl}\n\nThis link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not register, please ignore this email.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
<h2 style="margin-bottom: 8px;">Confirm your email</h2>
<p>Hello ${username || 'there'},</p>
<p>Thank you for registering. Please confirm your email by clicking the button below:</p>
<p style="margin: 18px 0;">
<a href="${verifyUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Confirm Email</a>
</p>
<p>Or copy this URL into your browser:</p>
<p><a href="${verifyUrl}">${verifyUrl}</a></p>
<p>This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.</p>
<p>If you did not register this account, you can ignore this message.</p>
</div>
`
});
return { sent: true };
} catch (err) {
console.error('Send verification email error:', err.message);
return {
sent: false,
reason: err.message
};
}
}
async function sendPasswordResetEmail({ email, username, token }) {
const resetUrl = getPasswordResetUrl(token);
if (!canSendEmails()) {
console.warn(`SMTP is not configured. Password reset URL for ${email}: ${resetUrl}`);
return {
sent: false,
previewUrl: resetUrl,
reason: 'SMTP is not configured'
};
}
try {
const transporter = getMailTransporter();
await transporter.sendMail({
from: SMTP_FROM,
to: email,
subject: 'AccManager - Reset your password',
text: `Hello ${username || 'there'},\n\nA password reset was requested for your account.\nOpen this link to set a new password:\n${resetUrl}\n\nThis link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.\n\nIf you did not request this, please ignore this email.`,
html: `
<div style="font-family: Arial, sans-serif; line-height: 1.5; color: #1f2937;">
<h2 style="margin-bottom: 8px;">Reset your password</h2>
<p>Hello ${username || 'there'},</p>
<p>We received a password reset request for your account.</p>
<p style="margin: 18px 0;">
<a href="${resetUrl}" style="background:#2563eb;color:#ffffff;padding:10px 16px;border-radius:8px;text-decoration:none;font-weight:700;display:inline-block;">Reset Password</a>
</p>
<p>Or copy this URL into your browser:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>This link will expire in ${PASSWORD_RESET_TOKEN_TTL_MINUTES} minutes.</p>
<p>If you did not request this reset, you can ignore this message.</p>
</div>
`
});
return { sent: true };
} catch (err) {
console.error('Send password reset email error:', err.message);
return {
sent: false,
reason: err.message
};
}
}
function getUserIdFromRequest(req) {
const rawUserId = req.headers['x-user-id'] || req.query.userId;
const userId = Number(rawUserId);
return Number.isInteger(userId) && userId > 0 ? userId : null;
}
async function getUserDisplayNameById(userId) {
if (!userId) {
return null;
}
try {
const result = await pool.request()
.input('userId', sql.Int, userId)
.query(`
SELECT TOP 1
NULLIF(LTRIM(RTRIM(FullName)), '') AS FullName,
NULLIF(LTRIM(RTRIM(Username)), '') AS Username
FROM Users
WHERE UserId = @userId
`);
const user = result.recordset?.[0];
return user?.FullName || user?.Username || null;
} catch (err) {
return null;
}
}
function normalizeDepartmentName(value) {
return String(value || '').trim();
}
function normalizeProjectName(value) {
return String(value || '').trim();
}
async function syncAssetDepartmentsFromInventory() {
if (!pool) {
return;
}
await pool.request().query(`
WITH SourceDepartments AS (
SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName
FROM AssetInventory
WHERE Department IS NOT NULL
AND LTRIM(RTRIM(Department)) <> ''
)
INSERT INTO AssetDepartments (DepartmentName)
SELECT source.DepartmentName
FROM SourceDepartments source
WHERE NOT EXISTS (
SELECT 1
FROM AssetDepartments target
WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName)
);
`);
}
async function ensurePasswordResetColumns() {
if (!pool) {
return;
}
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
}
async function ensureDepartmentExists(departmentName) {
const normalized = normalizeDepartmentName(departmentName);
if (!normalized || !pool) {
return;
}
await pool.request()
.input('departmentName', sql.NVarChar, normalized)
.query(`
IF NOT EXISTS (
SELECT 1
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
)
BEGIN
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
END
`);
}
function parsePositiveInteger(value, fallback = 1) {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
return fallback;
}
function parseNonNegativeInteger(value, fallback = 0) {
const parsed = parseAssetImportNumericValue(value, Number.NaN);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return fallback;
}
function parseOptionalNonNegativeInteger(value) {
if (value === undefined || value === null || String(value).trim() === '') {
return null;
}
const parsed = parseAssetImportNumericValue(value, Number.NaN);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
return null;
}
function parseNonNegativeIntegerOrFallback(value, fallback = 0) {
const parsed = parseOptionalNonNegativeInteger(value);
if (parsed !== null) {
return parsed;
}
return fallback;
}
function parseNullableDecimal(value) {
if (value === undefined || value === null) {
return null;
}
const normalized = String(value).trim().replace(/,/g, '');
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function parseNullableDate(value) {
if (value === undefined || value === null) {
return null;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
const directDate = new Date(raw);
if (!Number.isNaN(directDate.getTime())) {
return directDate;
}
const localDateParts = raw.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$/);
if (localDateParts) {
const day = Number(localDateParts[1]);
const month = Number(localDateParts[2]);
let year = Number(localDateParts[3]);
if (year < 100) {
year += 2000;
}
const localizedDate = new Date(year, month - 1, day);
if (!Number.isNaN(localizedDate.getTime())) {
return localizedDate;
}
}
return null;
}
function normalizeAssetStatus(value) {
const normalized = String(value || '').trim().toLowerCase();
if (['in_use', 'in use', 'dang su dung', 'active'].includes(normalized)) {
return 'in_use';
}
if (['maintenance', 'bao tri'].includes(normalized)) {
return 'maintenance';
}
if (['disposed', 'thanh ly', 'retired'].includes(normalized)) {
return 'disposed';
}
if (['in_stock', 'in stock', 'ton kho', 'warehouse'].includes(normalized)) {
return 'in_stock';
}
return 'in_use';
}
function normalizeAssetPayload(payload = {}) {
const quantity = parseNonNegativeIntegerOrFallback(payload.quantity, 0);
const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0);
const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0);
const providedEndingBalance = parseOptionalNonNegativeInteger(payload.endingBalance);
const endingBalance = providedEndingBalance !== null ? providedEndingBalance : 0;
return {
assetCode: String(payload.assetCode || '').trim(),
assetName: String(payload.assetName || '').trim(),
model: String(payload.model || '').trim() || null,
serialNumber: String(payload.serialNumber || '').trim() || null,
quantity,
unit: String(payload.unit || '').trim() || null,
department: String(payload.department || '').trim() || null,
project: String(payload.project || '').trim() || null,
importInPeriod,
exportInPeriod,
endingBalance,
location: String(payload.location || '').trim() || null,
custodian: String(payload.custodian || '').trim() || null,
borrower: String(payload.borrower || '').trim() || null,
purchaseDate: parseNullableDate(payload.purchaseDate),
purchasePrice: parseNullableDecimal(payload.purchasePrice),
status: normalizeAssetStatus(payload.status),
notes: String(payload.notes || '').trim() || null
};
}
function parseBorrowerEntries(rawBorrower) {
const source = String(rawBorrower || '').trim();
if (!source) {
return [];
}
const chunks = source
.split(/[\n;]+/g)
.map(item => String(item || '').trim())
.filter(Boolean);
const merged = [];
chunks.forEach(chunk => {
let name = chunk;
let quantity = 1;
const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i);
if (labeledMatch) {
name = String(labeledMatch[1] || '').trim();
quantity = parseNonNegativeInteger(labeledMatch[2], 1);
} else {
const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/);
const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i);
const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/);
const fallbackMatch = colonMatch || xMatch || parenMatch;
if (fallbackMatch) {
name = String(fallbackMatch[1] || '').trim();
quantity = parseNonNegativeInteger(fallbackMatch[2], 1);
}
}
if (!name || quantity <= 0) {
return;
}
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (existed) {
existed.quantity += quantity;
} else {
merged.push({ name, quantity });
}
});
return merged;
}
function formatBorrowerEntries(entries = []) {
if (!Array.isArray(entries) || !entries.length) {
return null;
}
const normalized = entries
.map(entry => ({
name: String(entry?.name || '').trim(),
quantity: parseNonNegativeInteger(entry?.quantity, 0)
}))
.filter(entry => entry.name && entry.quantity > 0);
if (!normalized.length) {
return null;
}
return normalized.map(entry => `${entry.name} - so luong: ${entry.quantity}`).join('; ');
}
function mergeBorrowerEntries(existingBorrower, borrowerName, borrowQuantity) {
const merged = parseBorrowerEntries(existingBorrower);
const name = String(borrowerName || '').trim();
const quantity = parseNonNegativeInteger(borrowQuantity, 0);
if (!name || quantity <= 0) {
return formatBorrowerEntries(merged);
}
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (existed) {
existed.quantity += quantity;
} else {
merged.push({ name, quantity });
}
return formatBorrowerEntries(merged);
}
function decreaseBorrowerEntries(existingBorrower, borrowerName, returnQuantity) {
const merged = parseBorrowerEntries(existingBorrower);
const name = String(borrowerName || '').trim();
const quantity = parseNonNegativeInteger(returnQuantity, 0);
if (!name || quantity <= 0) {
return {
success: false,
message: 'Invalid return payload',
entries: merged
};
}
const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase());
if (!existed) {
return {
success: false,
message: 'User has no borrowed quantity to return',
entries: merged
};
}
if (existed.quantity < quantity) {
return {
success: false,
message: `Return quantity (${quantity}) exceeds borrowed quantity (${existed.quantity})`,
entries: merged
};
}
existed.quantity -= quantity;
const normalized = merged.filter(entry => parseNonNegativeInteger(entry?.quantity, 0) > 0);
return {
success: true,
entries: normalized,
summary: formatBorrowerEntries(normalized)
};
}
function normalizeAssetRequestType(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'return' || normalized === 'tra' || normalized === 'return_asset') {
return 'return';
}
return 'borrow';
}
function normalizeAssetRequestStatus(value) {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') {
return 'approved';
}
if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') {
return 'rejected';
}
return 'pending';
}
function normalizeImportToken(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u0111\u0110]/g, 'd')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
function isHeaderLikeAssetImportRow(row = {}) {
const headerTokens = new Set([
'stt',
'ngayve',
'mavattu',
'mavt',
'mataisan',
'mats',
'matscd',
'tentaisan',
'tenlinhkiensp',
'model',
'dvt',
'donvi',
'tondauky',
'tondauki',
'nhaptrongky',
'nhaptrongki',
'xuattrongky',
'xuattrongki',
'toncuoiky',
'toncuoiki',
'lidoxuat',
'lydoxuat',
'tinhtrang',
'vitri',
'duan',
'assetcode',
'assetname',
'quantity',
'importinperiod',
'exportinperiod',
'endingbalance',
'unit',
'location',
'department',
'project',
'status',
'notes'
]);
const fields = [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.status,
row.location,
row.department,
row.project,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance,
row.notes
];
const headerLikeCount = fields.reduce((count, value) => {
const token = normalizeImportToken(value);
return count + (token && headerTokens.has(token) ? 1 : 0);
}, 0);
return headerLikeCount >= 2;
}
function isMeaningfulImportedAssetRow(row = {}) {
const assetName = String(row.assetName || '').trim();
if (!assetName) {
return false;
}
return [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.location,
row.department,
row.project,
row.notes,
row.quantity,
row.importInPeriod,
row.exportInPeriod,
row.endingBalance
].some(value => String(value ?? '').trim() !== '');
}
const ASSET_IMPORT_ALIASES = {
stt: ['STT', 'So thu tu'],
assetCode: ['Asset Code', 'Ma tai san', 'Ma TS', 'Ma TSCD', 'Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN', 'So the', 'So hieu', 'Ma tai san/CCDC'],
assetName: ['Asset Name', 'Ten tai san', 'Ten TS', 'Ten TSCD', 'Ten CCDC', 'Ten vat tu', 'Ten linh kien', 'Ten linh kien/sp', 'Ten linh kien sp', 'Ten sp', 'Name', 'Dien giai', 'Mo ta', 'Ten tai san/CCDC'],
model: ['Model', 'Dong may'],
serialNumber: ['Serial Number', 'Serial', 'So serial', 'So seri'],
quantity: ['Ton dau ky', 'Ton dau ki', 'Opening Balance', 'Quantity', 'So luong', 'SL'],
importInPeriod: ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap'],
exportInPeriod: ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat'],
endingBalance: ['Ton cuoi ky', 'Ton cuoi ki', 'Ton cuoi', 'Ending Balance'],
unit: ['Unit', 'Don vi', 'DVT'],
department: ['Department', 'Bo phan', 'Phong ban'],
project: ['Project', 'Du an', 'Cong trinh'],
location: ['Location', 'Vi tri', 'Noi dat'],
custodian: ['Custodian', 'Nguoi quan ly', 'Nguoi su dung'],
purchaseDate: ['Purchase Date', 'Ngay mua', 'Ngay nhap', 'Ngay ve'],
purchasePrice: ['Purchase Price', 'Gia mua', 'Don gia'],
status: ['Status', 'Trang thai', 'Tinh trang'],
notes: ['Notes', 'Ghi chu', 'Li do xuat', 'Ly do xuat']
};
function inferAssetFieldFromHeaderToken(headerToken) {
const token = String(headerToken || '');
if (!token) {
return null;
}
if (token.includes('model')) return 'model';
if (token.includes('serial') || token.includes('seri')) return 'serialNumber';
if (token.includes('tondau')) return 'quantity';
if (token.includes('nhaptrongky') || token.includes('nhaptrongki')) return 'importInPeriod';
if (token.includes('xuattrongky') || token.includes('xuattrongki')) return 'exportInPeriod';
if (token.includes('toncuoi')) return 'endingBalance';
if (token.includes('donvi') || token.includes('dvt') || token === 'unit') return 'unit';
if (token.includes('vitri') || token.includes('location')) return 'location';
if (token.includes('tinhtrang') || token === 'status') return 'status';
if (token.includes('duan') || token.includes('project')) return 'project';
if (token.includes('phongban') || token.includes('bophan') || token.includes('department')) return 'department';
if (token.includes('lydoxuat') || token.includes('lidoxuat') || token.includes('ghichu') || token === 'notes') return 'notes';
if (token.includes('soluong') || token === 'sl' || token === 'quantity') return 'quantity';
const hasTen = token.includes('ten');
const hasMa = token.includes('ma');
const hasAssetLike = token.includes('linhkien') || token.includes('vattu') || token.includes('sanpham') || token.includes('taisan') || token.includes('sp');
if (hasTen && hasAssetLike) return 'assetName';
if (hasMa && hasAssetLike) return 'assetCode';
return null;
}
function resolveAssetImportFieldByHeader(headerCell) {
const token = normalizeImportToken(headerCell);
if (!token) {
return null;
}
let bestField = null;
let bestScore = 0;
for (const [field, aliases] of Object.entries(ASSET_IMPORT_ALIASES)) {
for (const alias of aliases) {
const aliasToken = normalizeImportToken(alias);
if (!aliasToken) {
continue;
}
let score = 0;
if (token === aliasToken) {
score = 5;
} else if (token.includes(aliasToken) || aliasToken.includes(token)) {
score = 3;
}
if (score > bestScore) {
bestScore = score;
bestField = field;
}
}
}
if (bestField) {
return bestField;
}
return inferAssetFieldFromHeaderToken(token);
}
function buildAssetImportFieldMapFromHeaderRow(headerRow) {
const row = Array.isArray(headerRow) ? headerRow : [];
const fieldMap = {};
for (let index = 0; index < row.length; index += 1) {
const field = resolveAssetImportFieldByHeader(row[index]);
if (!field) {
continue;
}
if (fieldMap[field] === undefined) {
fieldMap[field] = index;
}
}
return fieldMap;
}
function scoreAssetImportFieldMap(fieldMap = {}) {
let score = 0;
const keys = Object.keys(fieldMap);
score += keys.length;
if (fieldMap.assetName !== undefined) score += 5;
if (fieldMap.assetCode !== undefined) score += 4;
if (fieldMap.model !== undefined) score += 3;
if (fieldMap.endingBalance !== undefined) score += 2;
if (fieldMap.importInPeriod !== undefined) score += 1;
if (fieldMap.exportInPeriod !== undefined) score += 1;
if (fieldMap.quantity !== undefined) score += 2;
if (fieldMap.unit !== undefined) score += 1;
if (fieldMap.location !== undefined) score += 1;
if (fieldMap.project !== undefined) score += 1;
return score;
}
function parseAssetImportRowsByHeaderMap(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 300);
let bestHeaderRowIndex = -1;
let bestFieldMap = {};
let bestScore = 0;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const headerRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
if (!headerRow.some(cell => String(cell ?? '').trim() !== '')) {
continue;
}
const candidateMap = buildAssetImportFieldMapFromHeaderRow(headerRow);
const score = scoreAssetImportFieldMap(candidateMap);
if (score > bestScore) {
bestScore = score;
bestHeaderRowIndex = rowIndex;
bestFieldMap = candidateMap;
}
}
if (bestHeaderRowIndex < 0 || bestScore < 2) {
return [];
}
const pick = (row, index) => {
if (!Array.isArray(row) || index === undefined || index < 0) {
return '';
}
return row[index] ?? '';
};
const parsed = rows
.slice(bestHeaderRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const endingBalance = parseAssetImportNumericValue(
pick(row, bestFieldMap.endingBalance),
0
);
const mapped = {
sourceStt: parseAssetImportSttNumber(pick(row, bestFieldMap.stt)),
assetCode: String(pick(row, bestFieldMap.assetCode)).trim(),
assetName: String(pick(row, bestFieldMap.assetName)).trim(),
model: String(pick(row, bestFieldMap.model)).trim(),
serialNumber: String(pick(row, bestFieldMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, bestFieldMap.quantity), 0),
importInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.importInPeriod), 0),
exportInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.exportInPeriod), 0),
endingBalance,
unit: String(pick(row, bestFieldMap.unit)).trim(),
department: String(pick(row, bestFieldMap.department)).trim(),
project: String(pick(row, bestFieldMap.project)).trim(),
location: String(pick(row, bestFieldMap.location)).trim(),
custodian: String(pick(row, bestFieldMap.custodian)).trim(),
purchaseDate: pick(row, bestFieldMap.purchaseDate),
purchasePrice: pick(row, bestFieldMap.purchasePrice),
status: String(pick(row, bestFieldMap.status)).trim(),
notes: String(pick(row, bestFieldMap.notes)).trim()
};
const hasAnyCoreValue = [mapped.assetCode, mapped.assetName, mapped.model, mapped.location, mapped.notes]
.some(value => String(value || '').trim() !== '');
if (!hasAnyCoreValue) {
return null;
}
return finalizeImportedAssetPayload(mapped, bestHeaderRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
return parsed;
}
function isAssetImportHeaderMatch(actualHeader, alias) {
const normalizedHeader = normalizeImportToken(actualHeader);
const normalizedAlias = normalizeImportToken(alias);
if (!normalizedHeader || !normalizedAlias) {
return false;
}
if (normalizedHeader === normalizedAlias) {
return true;
}
if (normalizedAlias.length < 4 || normalizedHeader.length < 4) {
return false;
}
return normalizedHeader.includes(normalizedAlias) || normalizedAlias.includes(normalizedHeader);
}
function inferAssetImportColumnIndex(headerRow, aliases = []) {
const row = Array.isArray(headerRow) ? headerRow : [];
for (let index = 0; index < row.length; index += 1) {
if (aliases.some(alias => isAssetImportHeaderMatch(row[index], alias))) {
return index;
}
}
return -1;
}
function parseAssetImportNumericValue(value, fallback = 0) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const raw = String(value).trim();
if (!raw) {
return fallback;
}
const compact = raw.replace(/\s+/g, '');
let normalized = compact;
const hasDot = compact.includes('.');
const hasComma = compact.includes(',');
// vi-VN style: 1.234,56 or 1.234
if (hasDot && hasComma) {
if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(compact)) {
normalized = compact.replace(/\./g, '').replace(',', '.');
} else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(compact)) {
// en-US style: 1,234.56
normalized = compact.replace(/,/g, '');
} else {
normalized = compact.replace(/,/g, '');
}
} else if (hasDot) {
if (/^-?\d{1,3}(\.\d{3})+$/.test(compact)) {
normalized = compact.replace(/\./g, '');
}
} else if (hasComma) {
if (/^-?\d{1,3}(,\d{3})+$/.test(compact)) {
normalized = compact.replace(/,/g, '');
} else if (/^-?\d+,\d+$/.test(compact)) {
normalized = compact.replace(',', '.');
} else {
normalized = compact.replace(/,/g, '');
}
}
if (!normalized) {
return fallback;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseAssetImportSttNumber(value) {
const raw = String(value ?? '')
.trim()
.replace(/\.$/, '')
.replace(',', '.');
if (!raw) {
return null;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
const rounded = Math.round(parsed);
return Math.abs(parsed - rounded) < 1e-9 ? rounded : null;
}
function sanitizeAssetCodeToken(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u0111\u0110]/g, 'd')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
function generateImportAssetCodeFromRow(mapped, rowNumber = 0) {
const fromModel = sanitizeAssetCodeToken(mapped.model);
const fromSerial = sanitizeAssetCodeToken(mapped.serialNumber);
const fromName = sanitizeAssetCodeToken(mapped.assetName);
const base = fromModel || fromSerial || fromName || 'ASSET';
const sttNumber = parseAssetImportSttNumber(mapped?.sourceStt);
const suffixSeed = sttNumber || rowNumber || 0;
const suffix = String(suffixSeed).padStart(4, '0');
return `IMP-${base}-${suffix}`;
}
function generateManualAssetCodeFromPayload(payload = {}) {
const fromModel = sanitizeAssetCodeToken(payload.model);
const fromSerial = sanitizeAssetCodeToken(payload.serialNumber);
const fromName = sanitizeAssetCodeToken(payload.assetName);
const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32);
const now = new Date();
const timestamp = [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(now.getMilliseconds()).padStart(3, '0')
].join('');
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`;
}
async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const candidate = generateManualAssetCodeFromPayload(payload);
const existed = await pool.request()
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
}
throw new Error('Cannot generate unique asset code');
}
function finalizeImportedAssetPayload(mapped, rowNumber = 0) {
const result = { ...mapped };
if (!result.assetName) {
result.assetName = String(result.model || result.serialNumber || result.assetCode || '').trim();
}
if (!result.assetCode && result.assetName) {
result.assetCode = generateImportAssetCodeFromRow(result, rowNumber);
}
return result;
}
function buildAssetImportIndexMap(headerRow) {
const indexMap = {
stt: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.stt),
assetCode: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetCode),
assetName: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetName),
model: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.model),
serialNumber: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.serialNumber),
quantity: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.quantity),
importInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.importInPeriod),
exportInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.exportInPeriod),
endingBalance: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.endingBalance),
unit: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.unit),
department: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.department),
project: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.project),
location: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.location),
custodian: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.custodian),
purchaseDate: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchaseDate),
purchasePrice: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchasePrice),
status: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.status),
notes: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.notes)
};
if (indexMap.stt >= 0) {
if (indexMap.purchaseDate < 0) indexMap.purchaseDate = indexMap.stt + 1;
if (indexMap.assetCode < 0) indexMap.assetCode = indexMap.stt + 2;
if (indexMap.assetName < 0) indexMap.assetName = indexMap.stt + 3;
if (indexMap.model < 0) indexMap.model = indexMap.stt + 4;
if (indexMap.unit < 0) indexMap.unit = indexMap.stt + 5;
if (indexMap.quantity < 0) indexMap.quantity = indexMap.stt + 6;
if (indexMap.importInPeriod < 0) indexMap.importInPeriod = indexMap.stt + 7;
if (indexMap.exportInPeriod < 0) indexMap.exportInPeriod = indexMap.stt + 8;
if (indexMap.endingBalance < 0) indexMap.endingBalance = indexMap.stt + 9;
if (indexMap.notes < 0) indexMap.notes = indexMap.stt + 10;
if (indexMap.status < 0) indexMap.status = indexMap.stt + 11;
if (indexMap.location < 0) indexMap.location = indexMap.stt + 12;
if (indexMap.project < 0) indexMap.project = indexMap.stt + 13;
}
return indexMap;
}
function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) {
const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : [];
if (!headerRow.length) {
return [];
}
const indexMap = buildAssetImportIndexMap(headerRow);
if (indexMap.assetCode < 0 && indexMap.assetName < 0 && indexMap.model < 0 && indexMap.stt < 0) {
return [];
}
const pick = (row, index) => {
if (index < 0 || !Array.isArray(row)) {
return '';
}
return row[index] ?? '';
};
return matrixRows
.slice(headerRowIndex + 1)
.filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
.map((row, rowOffset) => {
const sttValue = parseAssetImportSttNumber(pick(row, indexMap.stt));
if (indexMap.stt >= 0 && sttValue === null) {
return null;
}
const endingBalance = parseAssetImportNumericValue(
pick(row, indexMap.endingBalance),
0
);
const mapped = {
sourceStt: sttValue,
assetCode: String(pick(row, indexMap.assetCode)).trim(),
assetName: String(pick(row, indexMap.assetName)).trim(),
model: String(pick(row, indexMap.model)).trim(),
serialNumber: String(pick(row, indexMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, indexMap.quantity), 0),
importInPeriod: parseAssetImportNumericValue(pick(row, indexMap.importInPeriod), 0),
exportInPeriod: parseAssetImportNumericValue(pick(row, indexMap.exportInPeriod), 0),
endingBalance,
unit: String(pick(row, indexMap.unit)).trim(),
department: String(pick(row, indexMap.department)).trim(),
project: String(pick(row, indexMap.project)).trim(),
location: String(pick(row, indexMap.location)).trim(),
custodian: String(pick(row, indexMap.custodian)).trim(),
purchaseDate: pick(row, indexMap.purchaseDate),
purchasePrice: pick(row, indexMap.purchasePrice),
status: String(pick(row, indexMap.status)).trim(),
notes: String(pick(row, indexMap.notes)).trim()
};
return finalizeImportedAssetPayload(mapped, headerRowIndex + rowOffset + 2);
})
.filter(Boolean)
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function parseAssetImportRowsFromMatrix(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxScanRows = Math.min(rows.length, 300);
let bestRows = [];
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
if (!row.length) {
continue;
}
const hasStt = row.some(cell => ASSET_IMPORT_ALIASES.stt.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasName = row.some(cell => ASSET_IMPORT_ALIASES.assetName.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasModel = row.some(cell => ASSET_IMPORT_ALIASES.model.some(alias => isAssetImportHeaderMatch(cell, alias)));
const hasQty = row.some(cell => ASSET_IMPORT_ALIASES.quantity.some(alias => isAssetImportHeaderMatch(cell, alias)));
if (!hasStt || (!hasName && !hasModel && !hasQty)) {
continue;
}
const candidateRows = mapAssetImportMatrixRowsByIndex(rows, rowIndex);
if (candidateRows.length > bestRows.length) {
bestRows = candidateRows;
}
}
if (bestRows.length >= 3) {
return bestRows;
}
let detectedSttCol = -1;
for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
const row = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
const col = inferAssetImportColumnIndex(row, ASSET_IMPORT_ALIASES.stt);
if (col >= 0) {
detectedSttCol = col;
break;
}
}
if (detectedSttCol < 0) {
detectedSttCol = 0;
}
const sttRows = rows.filter(row => {
if (!Array.isArray(row)) {
return false;
}
const sttValue = parseAssetImportSttNumber(row[detectedSttCol]);
if (sttValue === null) {
return false;
}
return [2, 3, 4, 5, 9, 12]
.map(offset => detectedSttCol + offset)
.some(index => String(row[index] ?? '').trim() !== '');
});
if (sttRows.length < 3) {
return bestRows;
}
return sttRows
.map((row, idx) => {
const sttValue = parseAssetImportSttNumber(row[detectedSttCol]);
const endingBalance = parseAssetImportNumericValue(row[detectedSttCol + 9] ?? '', 0);
const mapped = {
sourceStt: sttValue,
assetCode: String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
assetName: String(row[detectedSttCol + 3] ?? '').trim() || String(row[detectedSttCol + 2] ?? '').trim() || String(row[detectedSttCol + 4] ?? '').trim(),
model: String(row[detectedSttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[detectedSttCol + 6] ?? '', 0),
importInPeriod: parseAssetImportNumericValue(row[detectedSttCol + 7] ?? '', 0),
exportInPeriod: parseAssetImportNumericValue(row[detectedSttCol + 8] ?? '', 0),
endingBalance,
unit: String(row[detectedSttCol + 5] ?? '').trim(),
department: '',
project: String(row[detectedSttCol + 13] ?? '').trim(),
location: String(row[detectedSttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[detectedSttCol + 1] ?? '',
purchasePrice: '',
status: String(row[detectedSttCol + 11] ?? '').trim(),
notes: String(row[detectedSttCol + 10] ?? '').trim()
};
return finalizeImportedAssetPayload(mapped, idx + 2);
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function detectLikelySttColumn(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const maxCols = Math.min(
rows.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0),
40
);
let bestColumn = -1;
let bestScore = 0;
const scoreColumn = col => {
let validCount = 0;
let sequentialHits = 0;
let prev = null;
for (const row of rows.slice(0, 500)) {
if (!Array.isArray(row)) {
continue;
}
const value = parseAssetImportSttNumber(row[col]);
if (value === null) {
continue;
}
validCount += 1;
if (prev !== null && value === prev + 1) {
sequentialHits += 1;
}
prev = value;
}
return (validCount * 2) + (sequentialHits * 5);
};
for (let col = 0; col < maxCols; col += 1) {
const score = scoreColumn(col);
if (score > bestScore) {
bestScore = score;
bestColumn = col;
}
}
return bestScore >= 12 ? bestColumn : -1;
}
function parseAssetImportRowsLoose(matrixRows) {
const rows = Array.isArray(matrixRows) ? matrixRows : [];
const sttCol = detectLikelySttColumn(rows);
if (sttCol < 0) {
return [];
}
const dataRows = rows.filter(row => {
if (!Array.isArray(row)) {
return false;
}
const stt = parseAssetImportSttNumber(row[sttCol]);
if (stt === null) {
return false;
}
const hasCoreValue = [2, 3, 4, 5, 9, 12]
.map(offset => sttCol + offset)
.some(index => String(row[index] ?? '').trim() !== '');
return hasCoreValue;
});
return dataRows
.map((row, idx) => {
const sttValue = parseAssetImportSttNumber(row[sttCol]);
const endingBalance = parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0);
const mapped = {
sourceStt: sttValue,
assetCode: String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(),
assetName: String(row[sttCol + 3] ?? '').trim() || String(row[sttCol + 2] ?? '').trim() || String(row[sttCol + 4] ?? '').trim(),
model: String(row[sttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[sttCol + 6] ?? '', 0),
importInPeriod: parseAssetImportNumericValue(row[sttCol + 7] ?? '', 0),
exportInPeriod: parseAssetImportNumericValue(row[sttCol + 8] ?? '', 0),
endingBalance,
unit: String(row[sttCol + 5] ?? '').trim(),
department: '',
project: String(row[sttCol + 13] ?? '').trim(),
location: String(row[sttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[sttCol + 1] ?? '',
purchasePrice: '',
status: String(row[sttCol + 11] ?? '').trim(),
notes: String(row[sttCol + 10] ?? '').trim()
};
return finalizeImportedAssetPayload(mapped, idx + 2);
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
}
function parseAssetImportRows(matrixRows) {
const genericRows = parseAssetImportRowsByHeaderMap(matrixRows);
if (genericRows.length > 0) {
return genericRows;
}
const strictRows = parseAssetImportRowsFromMatrix(matrixRows);
if (strictRows.length > 0) {
return strictRows;
}
return parseAssetImportRowsLoose(matrixRows);
}
function countNonEmptyMatrixRows(matrixRows) {
return (Array.isArray(matrixRows) ? matrixRows : []).filter(
row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')
).length;
}
async function ensureUniqueImportAssetCode(transaction, initialCode, maxAttempts = 12) {
let candidate = String(initialCode || '').trim();
if (!candidate) {
candidate = generateManualAssetCodeFromPayload({});
}
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const existed = await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, candidate)
.query(`
SELECT TOP 1 AssetId
FROM AssetInventory
WHERE AssetCode = @assetCode
`);
if (existed.recordset.length === 0) {
return candidate;
}
const suffix = String(attempt + 1).padStart(2, '0');
candidate = `${String(initialCode || 'IMP-ASSET').slice(0, 90)}-${suffix}`;
}
return generateManualAssetCodeFromPayload({ assetName: initialCode || 'IMPORT' });
}
function scoreAssetImportSheetName(sheetName = '') {
const token = normalizeImportToken(sheetName);
if (!token) {
return 0;
}
let score = 0;
if (token.includes('xuatnhapton')) score += 120;
if (token.includes('kho')) score += 60;
if (token.includes('robotics')) score += 40;
if (token.includes('inventory')) score += 40;
if (token.includes('asset')) score += 30;
return score;
}
function parseAssetImportRowsFromWorkbook(workbook) {
const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : [];
let bestRows = [];
let bestSheetName = '';
let bestNonEmptyRows = 0;
let bestSheetPriority = 0;
const diagnostics = [];
for (const sheetName of sheetNames) {
const sheet = workbook.Sheets?.[sheetName];
if (!sheet) {
continue;
}
const matrixRows = XLSX.utils.sheet_to_json(sheet, {
header: 1,
defval: '',
raw: false
});
const parsedRows = parseAssetImportRows(matrixRows);
const nonEmptyRows = countNonEmptyMatrixRows(matrixRows);
const sheetPriority = scoreAssetImportSheetName(sheetName);
diagnostics.push({
sheetName,
parsedRows: parsedRows.length,
nonEmptyRows,
sheetPriority
});
if (
(sheetPriority > bestSheetPriority && parsedRows.length > 0)
|| (sheetPriority === bestSheetPriority && parsedRows.length > bestRows.length)
|| (sheetPriority === bestSheetPriority && parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)
) {
bestRows = parsedRows;
bestSheetName = sheetName;
bestNonEmptyRows = nonEmptyRows;
bestSheetPriority = sheetPriority;
}
}
return {
rows: bestRows,
sheetName: bestSheetName,
diagnostics
};
}
// Middleware
app.use(cors());
app.use(express.json());
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 }
});
// Serve static files from /public
const path = require('path');
const publicDir = path.join(__dirname, '..', 'public');
app.use(express.static(publicDir));
// Root route
app.get('/', (req, res) => {
res.sendFile(path.join(publicDir, 'pages', 'login.html'));
});
// SQL Server Configuration
const sqlConfig = {
server: DB_SERVER,
authentication: {
type: 'default',
options: {
userName: DB_USER,
password: DB_PASSWORD
}
},
options: {
database: DB_NAME,
trustServerCertificate: DB_TRUST_CERTIFICATE,
enableKeepAlive: true,
connectTimeout: DB_CONNECT_TIMEOUT,
encrypt: DB_ENCRYPT
}
};
// Initialize Database Pool
let pool;
async function initializeDatabase() {
try {
pool = new sql.ConnectionPool(sqlConfig);
await pool.connect();
console.log('[OK] Connected to SQL Server');
// Check and create database if not exists
const masterConnection = new sql.ConnectionPool({
server: DB_SERVER,
authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } },
options: {
connectTimeout: DB_CONNECT_TIMEOUT,
database: 'master',
trustServerCertificate: DB_TRUST_CERTIFICATE,
encrypt: DB_ENCRYPT
}
});
await masterConnection.connect();
const createDbResult = await masterConnection.request()
.query(`IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'AccManager')
BEGIN
CREATE DATABASE AccManager;
END`);
await masterConnection.close();
// Now create tables in AccManager
await createTables();
await migrateLegacyPasswords();
console.log('[OK] Database and tables created');
} catch (err) {
console.error('Database connection failed:', err);
process.exit(1);
}
}
async function migrateLegacyPasswords() {
try {
const usersResult = await pool.request()
.query('SELECT UserId, Password, ViewPassword FROM Users WHERE Password IS NOT NULL');
let migratedCount = 0;
for (const row of usersResult.recordset) {
const request = pool.request()
.input('userId', sql.Int, row.UserId);
let hasUpdates = false;
const rawPassword = String(row.Password || '');
if (!row.ViewPassword && !isBcryptHash(rawPassword)) {
request.input('viewPassword', sql.NVarChar, encryptPasswordForView(rawPassword));
hasUpdates = true;
}
if (!isBcryptHash(row.Password)) {
const hashedPassword = await hashPassword(row.Password);
request.input('password', sql.NVarChar, hashedPassword);
hasUpdates = true;
migratedCount += 1;
}
if (hasUpdates) {
await request.query(`UPDATE Users
SET ${!isBcryptHash(rawPassword) ? 'Password = @password' : 'Password = Password'}
${(!row.ViewPassword && !isBcryptHash(rawPassword)) ? ', ViewPassword = @viewPassword' : ''}
WHERE UserId = @userId`);
}
}
if (migratedCount > 0) {
console.log(`[OK] Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
}
} catch (err) {
console.error('Password migration error:', err.message);
}
}
async function createTables() {
const queries = [
// Users Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users')
BEGIN
CREATE TABLE Users (
UserId INT PRIMARY KEY IDENTITY(1,1),
Username NVARCHAR(50) UNIQUE NOT NULL,
Password NVARCHAR(255) NOT NULL,
Email NVARCHAR(100),
FullName NVARCHAR(100),
Role NVARCHAR(50) NOT NULL,
Status NVARCHAR(20) DEFAULT 'Active',
CreatedDate DATETIME DEFAULT GETDATE(),
LastLogin DATETIME,
IsActive BIT DEFAULT 1
)
END`,
// Applications Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Applications')
BEGIN
CREATE TABLE Applications (
AppId INT PRIMARY KEY IDENTITY(1,1),
Name NVARCHAR(100) NOT NULL,
Type NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'online',
Icon NVARCHAR(50),
Description NVARCHAR(500),
Url NVARCHAR(255),
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
)
END`,
// Accounts Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Accounts')
BEGIN
CREATE TABLE Accounts (
AccountId INT PRIMARY KEY IDENTITY(1,1),
UserId INT NOT NULL,
AppId INT NOT NULL,
AccountUsername NVARCHAR(100),
AccountPassword NVARCHAR(255),
Email NVARCHAR(100),
AccessLevel NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'Active',
Notes NVARCHAR(MAX),
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE(),
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE,
FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE
)
END`,
// Asset Inventory Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
BEGIN
CREATE TABLE AssetInventory (
AssetId INT PRIMARY KEY IDENTITY(1,1),
AssetCode NVARCHAR(100) NOT NULL UNIQUE,
AssetName NVARCHAR(255) NOT NULL,
Model NVARCHAR(255),
SerialNumber NVARCHAR(100),
Quantity INT NOT NULL DEFAULT 0,
ImportInPeriod INT NOT NULL DEFAULT 0,
ExportInPeriod INT NOT NULL DEFAULT 0,
EndingBalance INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50),
Department NVARCHAR(100),
Project NVARCHAR(150),
Location NVARCHAR(150),
Custodian NVARCHAR(100),
Borrower NVARCHAR(255),
ExportedBy NVARCHAR(100),
PurchaseDate DATE NULL,
PurchasePrice DECIMAL(18,2) NULL,
Status NVARCHAR(30) NOT NULL DEFAULT 'in_use',
Notes NVARCHAR(MAX),
CreatedBy INT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE(),
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// Asset Departments Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
)
END`,
// Asset Projects Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetProjects')
BEGIN
CREATE TABLE AssetProjects (
ProjectId INT PRIMARY KEY IDENTITY(1,1),
ProjectName NVARCHAR(150) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE()
)
END`,
// Asset Borrow Requests Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests')
BEGIN
CREATE TABLE AssetBorrowRequests (
BorrowId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow',
RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending',
BorrowerName NVARCHAR(100) NOT NULL,
BorrowQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE),
RequestNote NVARCHAR(500) NULL,
RejectReason NVARCHAR(1000) NULL,
CreatedBy INT NULL,
ProcessedBy INT NULL,
ProcessedByName NVARCHAR(100) NULL,
ProcessedDate DATETIME NULL,
CreatedDate DATETIME DEFAULT GETDATE(),
UpdatedDate DATETIME DEFAULT GETDATE(),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL,
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
CREATE TABLE AuditLog (
LogId INT PRIMARY KEY IDENTITY(1,1),
UserId INT,
Action NVARCHAR(50),
TableName NVARCHAR(50),
RecordId INT,
OldValue NVARCHAR(MAX),
NewValue NVARCHAR(MAX),
Timestamp DATETIME DEFAULT GETDATE(),
FOREIGN KEY (UserId) REFERENCES Users(UserId)
)
END`
];
for (let query of queries) {
try {
await pool.request().query(query);
} catch (err) {
console.error('Table creation error:', err.message);
}
}
// Ensure AssetInventory indexes exist for lookup/filter performance
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`);
} catch (err) {
console.error('AssetInventory index creation error:', err.message);
}
// Ensure AssetDepartments indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`);
} catch (err) {
console.error('AssetDepartments index creation error:', err.message);
}
// Ensure AssetProjects indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetProjects_ProjectName') CREATE UNIQUE INDEX UX_AssetProjects_ProjectName ON AssetProjects(ProjectName);`);
} catch (err) {
console.error('AssetProjects index creation error:', err.message);
}
// Ensure AssetBorrowRequests indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_BorrowDate') CREATE INDEX IX_AssetBorrowRequests_BorrowDate ON AssetBorrowRequests(BorrowDate DESC);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestStatus') CREATE INDEX IX_AssetBorrowRequests_RequestStatus ON AssetBorrowRequests(RequestStatus);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestType') CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);`);
} catch (err) {
console.error('AssetBorrowRequests index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
SELECT 1
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.AssetInventory')
AND name = 'Model'
AND max_length < 510
)
ALTER TABLE AssetInventory ALTER COLUMN Model NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ImportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ImportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ExportInPeriod DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','EndingBalance') IS NULL ALTER TABLE AssetInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_AssetInventory_EndingBalance DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Project') IS NULL ALTER TABLE AssetInventory ADD Project NVARCHAR(150) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Borrower') IS NULL ALTER TABLE AssetInventory ADD Borrower NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportedBy') IS NULL ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','Unit') IS NULL ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','BorrowDate') IS NULL ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE));`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestType') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow');`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestStatus') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved');`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestNote') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RejectReason') IS NULL ALTER TABLE AssetBorrowRequests ADD RejectReason NVARCHAR(1000) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedBy') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedBy INT NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedByName') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedByName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedDate DATETIME NULL;`);
await pool.request().query(`
IF NOT EXISTS (
SELECT 1
FROM sys.foreign_keys
WHERE name = 'FK_AssetBorrowRequests_ProcessedBy'
)
ALTER TABLE AssetBorrowRequests
ADD CONSTRAINT FK_AssetBorrowRequests_ProcessedBy
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`);
await pool.request().query(`
UPDATE ai
SET ai.ExportedBy = COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), NULLIF(LTRIM(RTRIM(u.Username)), ''))
FROM AssetInventory ai
LEFT JOIN Users u ON ai.CreatedBy = u.UserId
WHERE ai.ExportedBy IS NULL
`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Category') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Category;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Brand') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Brand;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','WarrantyUntil') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN WarrantyUntil;`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerified') IS NULL ALTER TABLE Users ADD EmailVerified BIT NOT NULL CONSTRAINT DF_Users_EmailVerified DEFAULT(0);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifiedAt') IS NULL ALTER TABLE Users ADD EmailVerifiedAt DATETIME NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyToken') IS NULL ALTER TABLE Users ADD EmailVerifyToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyTokenExpires') IS NULL ALTER TABLE Users ADD EmailVerifyTokenExpires DATETIME NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetToken') IS NULL ALTER TABLE Users ADD PasswordResetToken NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','PasswordResetTokenExpires') IS NULL ALTER TABLE Users ADD PasswordResetTokenExpires DATETIME NULL;`);
await pool.request().query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE LOWER(ISNULL(Role, '')) = 'admin';`);
// Backfill Url to empty string to avoid undefined in responses
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
} catch (err) {
console.error('Column addition error (Applications):', err.message);
}
// Sync legacy departments from AssetInventory to AssetDepartments
try {
await syncAssetDepartmentsFromInventory();
} catch (err) {
console.error('AssetDepartments sync error:', err.message);
}
// Insert initial admin user
try {
const adminPasswordHash = await hashPassword('admin');
const adminViewPassword = encryptPasswordForView('admin');
await pool.request()
.input('username', sql.NVarChar, 'admin')
.input('password', sql.NVarChar, adminPasswordHash)
.input('viewPassword', sql.NVarChar, adminViewPassword)
.input('email', sql.NVarChar, 'admin@accmanager.local')
.input('fullname', sql.NVarChar, 'Administrator')
.input('role', sql.NVarChar, 'admin')
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt)
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1, 1, GETDATE())
ELSE
UPDATE Users
SET EmailVerified = 1,
EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE())
WHERE Username = @username`);
console.log('[OK] Admin user created: admin / admin');
} catch (err) {
console.error('Admin user error:', err.message);
}
// Insert sample applications
try {
await pool.request()
.query(`IF (SELECT COUNT(*) FROM Applications) = 0
BEGIN
INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
VALUES
('AWS', 'Cloud', 'online', 'cloud', 'Amazon Web Services', 'https://aws.amazon.com'),
('GitHub', 'VCS', 'online', 'code', 'GitHub - Version Control', 'https://github.com'),
('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'),
('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org')
END`);
console.log('[OK] Sample applications created');
} catch (err) {
console.error('Applications error:', err.message);
}
}
// ==========================================
// API ROUTES - Authentication
// ==========================================
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
message: 'Username and password are required'
});
}
const result = await pool.request()
.input('username', sql.NVarChar, username)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password, EmailVerified
FROM Users
WHERE (Username = @username OR Email = @username)
AND IsActive = 1`);
if (result.recordset.length > 0) {
const dbUser = result.recordset[0];
const isValidPassword = await verifyPassword(password, dbUser.Password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
if (!dbUser.EmailVerified) {
return res.status(403).json({
success: false,
message: 'Please confirm your email before signing in',
requiresEmailVerification: true,
email: dbUser.Email,
username: dbUser.Username
});
}
// Upgrade old plain-text passwords after successful legacy login.
if (!isBcryptHash(dbUser.Password)) {
const upgradedHash = await hashPassword(password);
await pool.request()
.input('userId', sql.Int, dbUser.UserId)
.input('password', sql.NVarChar, upgradedHash)
.input('viewPassword', sql.NVarChar, encryptPasswordForView(password))
.query('UPDATE Users SET Password = @password, ViewPassword = ISNULL(ViewPassword, @viewPassword) WHERE UserId = @userId');
}
const { Password: _, ...safeUser } = dbUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
// Update last login
await pool.request()
.input('userId', sql.Int, user.UserId)
.query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId');
res.json({
success: true,
message: 'Login successful',
user: user
});
} else {
res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ success: false, message: err.message });
}
});
// Public registration endpoint
app.post('/api/auth/register', async (req, res) => {
try {
const { username, password, email, fullname } = req.body;
if (!username || !password || !email) {
return res.status(400).json({ success: false, message: 'Username, password and email are required' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const safeUsername = String(username).trim();
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
// Prevent duplicate usernames/emails
const existing = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('email', sql.NVarChar, normalizedEmail)
.query('SELECT TOP 1 UserId, Username, Email FROM Users WHERE Username = @username OR Email = @email');
if (existing.recordset.length > 0) {
const existed = existing.recordset[0];
const duplicateByEmail = String(existed.Email || '').toLowerCase() === normalizedEmail;
return res.status(409).json({
success: false,
message: duplicateByEmail ? 'Email already exists' : 'Username already exists'
});
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
const { token, tokenHash } = generateEmailVerificationToken();
const safeFullname = fullname && fullname.trim() ? fullname.trim() : safeUsername;
let guestRoleName = 'guest';
let guestRoleId = null;
const hasRoleIdColResult = await pool.request()
.query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId");
const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1;
const hasRolesTableResult = await pool.request()
.query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable");
const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1;
if (hasRolesTable) {
const guestRoleResult = await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest')
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES ('Guest', 'Default role for self-registered users');
END
SELECT TOP 1 RoleId, RoleName
FROM Roles
WHERE LOWER(RoleName) = 'guest';
`);
if (guestRoleResult.recordset.length > 0) {
guestRoleId = guestRoleResult.recordset[0].RoleId;
guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest';
}
}
let result;
if (hasRoleIdColumn && guestRoleId !== null) {
result = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('roleId', sql.Int, guestRoleId)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
} else {
result = await pool.request()
.input('username', sql.NVarChar, safeUsername)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, normalizedEmail)
.input('fullname', sql.NVarChar, safeFullname)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.input('emailVerifyToken', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`);
}
const inserted = result.recordset[0];
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
if (hasRoleIdColumn && guestRoleId !== null) {
await pool.request()
.input('guestRoleId', sql.Int, guestRoleId)
.query(`UPDATE Users
SET RoleId = @guestRoleId,
Role = 'Guest'
WHERE LOWER(Role) = 'guest'
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
}
const emailResult = await sendVerificationEmail({
email: normalizedEmail,
username: safeUsername,
token
});
const responsePayload = {
success: true,
message: emailResult.sent
? 'Registration successful. Please check your email to confirm your account.'
: 'Registration successful, but email sending is not configured. Please contact administrator or use verification preview link in development.',
registrationPendingVerification: true,
emailSent: emailResult.sent,
userId: inserted?.UserId
};
if (emailResult.previewUrl) {
responsePayload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailResult.reason && !emailResult.sent) {
responsePayload.emailError = emailResult.reason;
}
res.json(responsePayload);
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ success: false, message: 'Registration failed' });
}
});
app.get('/api/auth/verify-email', async (req, res) => {
try {
const token = String(req.query.token || '').trim();
if (!token) {
return res.status(400).json({ success: false, message: 'Verification token is required' });
}
const tokenHash = hashVerificationToken(token);
const result = await pool.request()
.input('token', sql.NVarChar, tokenHash)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, EmailVerifyTokenExpires
FROM Users
WHERE EmailVerifyToken = @token`);
if (result.recordset.length === 0) {
return res.status(400).json({ success: false, message: 'Verification token is invalid or already used' });
}
const verifiedUser = result.recordset[0];
const expiresAt = verifiedUser.EmailVerifyTokenExpires ? new Date(verifiedUser.EmailVerifyTokenExpires) : null;
if (!expiresAt || expiresAt.getTime() < Date.now()) {
return res.status(400).json({ success: false, message: 'Verification token has expired. Please request a new email.' });
}
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query(`UPDATE Users
SET EmailVerified = 1,
EmailVerifiedAt = GETDATE(),
EmailVerifyToken = NULL,
EmailVerifyTokenExpires = NULL
WHERE UserId = @userId`);
if (!verifiedUser.IsActive) {
return res.status(403).json({
success: false,
message: 'Email confirmed, but account is inactive. Please contact administrator.'
});
}
// Align verification flow with login payload shape to support auto-login on verify page.
const { EmailVerifyTokenExpires: _expires, ...safeUser } = verifiedUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
await pool.request()
.input('userId', sql.Int, verifiedUser.UserId)
.query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId');
res.json({
success: true,
message: 'Email confirmed successfully. Logging you in...',
autoLogin: true,
user
});
} catch (err) {
console.error('Verify email error:', err.message);
res.status(500).json({ success: false, message: 'Email verification failed' });
}
});
app.post('/api/auth/resend-verification', async (req, res) => {
try {
const identifier = String(req.body?.identifier || req.body?.email || '').trim();
if (!identifier) {
return res.status(400).json({ success: false, message: 'Username or email is required' });
}
const result = await pool.request()
.input('identifier', sql.NVarChar, identifier)
.query(`SELECT TOP 1 UserId, Username, Email, EmailVerified
FROM Users
WHERE Username = @identifier OR Email = @identifier`);
if (result.recordset.length === 0) {
return res.json({ success: true, message: 'If the account exists, a confirmation email has been sent.' });
}
const user = result.recordset[0];
if (user.EmailVerified) {
return res.json({ success: true, message: 'This email is already confirmed.' });
}
if (!user.Email) {
return res.status(400).json({ success: false, message: 'This account has no email. Please contact administrator.' });
}
const { token, tokenHash } = generateEmailVerificationToken();
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('tokenHash', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES)
.query(`UPDATE Users
SET EmailVerifyToken = @tokenHash,
EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())
WHERE UserId = @userId`);
const emailResult = await sendVerificationEmail({
email: user.Email,
username: user.Username,
token
});
const payload = {
success: true,
message: emailResult.sent
? 'Verification email sent. Please check your inbox.'
: 'SMTP is not configured. Use development preview link or configure SMTP.',
emailSent: emailResult.sent
};
if (emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Resend verification error:', err.message);
res.status(500).json({ success: false, message: 'Cannot resend verification email right now' });
}
});
app.post('/api/auth/forgot-password', async (req, res) => {
try {
await ensurePasswordResetColumns();
const username = String(req.body?.username || '').trim();
const email = String(req.body?.email || '').trim().toLowerCase();
if (!username || !email) {
return res.status(400).json({ success: false, message: 'Username and email are required' });
}
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('email', sql.NVarChar, email)
.query(`SELECT TOP 1 UserId, Username, Email, IsActive
FROM Users
WHERE Username = @username
AND LOWER(ISNULL(Email, '')) = @email`);
if (result.recordset.length === 0 || !result.recordset[0].IsActive) {
return res.json({
success: true,
message: 'If the username and email match, a password reset email has been sent.'
});
}
const user = result.recordset[0];
const { token, tokenHash } = generateEmailVerificationToken();
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('tokenHash', sql.NVarChar, tokenHash)
.input('tokenTtlMinutes', sql.Int, PASSWORD_RESET_TOKEN_TTL_MINUTES)
.query(`UPDATE Users
SET PasswordResetToken = @tokenHash,
PasswordResetTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())
WHERE UserId = @userId`);
const emailResult = await sendPasswordResetEmail({
email: user.Email,
username: user.Username,
token
});
const payload = {
success: true,
message: emailResult.sent
? 'Password reset email sent. Please check your inbox.'
: 'SMTP is not configured. Use development reset link or configure SMTP.',
emailSent: emailResult.sent
};
if (emailResult.previewUrl) {
payload.resetPreviewUrl = emailResult.previewUrl;
}
if (emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Forgot password error:', err.message);
res.status(500).json({ success: false, message: 'Cannot process forgot password request right now' });
}
});
app.post('/api/auth/reset-password', async (req, res) => {
try {
await ensurePasswordResetColumns();
const token = String(req.body?.token || '').trim();
const newPassword = String(req.body?.newPassword || req.body?.password || '');
if (!token || !newPassword) {
return res.status(400).json({ success: false, message: 'Reset token and new password are required' });
}
if (newPassword.length < 6) {
return res.status(400).json({ success: false, message: 'New password must be at least 6 characters' });
}
const tokenHash = hashVerificationToken(token);
const result = await pool.request()
.input('token', sql.NVarChar, tokenHash)
.query(`SELECT TOP 1 UserId, PasswordResetTokenExpires, IsActive
FROM Users
WHERE PasswordResetToken = @token`);
if (result.recordset.length === 0) {
return res.status(400).json({ success: false, message: 'Reset token is invalid or already used' });
}
const user = result.recordset[0];
if (!user.IsActive) {
return res.status(403).json({ success: false, message: 'Account is inactive. Please contact administrator.' });
}
const expiresAt = user.PasswordResetTokenExpires ? new Date(user.PasswordResetTokenExpires) : null;
if (!expiresAt || expiresAt.getTime() < Date.now()) {
return res.status(400).json({ success: false, message: 'Reset token has expired. Please request a new reset email.' });
}
const hashedPassword = await hashPassword(newPassword);
const viewPassword = encryptPasswordForView(newPassword);
await pool.request()
.input('userId', sql.Int, user.UserId)
.input('password', sql.NVarChar, hashedPassword)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`UPDATE Users
SET Password = @password,
ViewPassword = @viewPassword,
PasswordResetToken = NULL,
PasswordResetTokenExpires = NULL
WHERE UserId = @userId`);
res.json({ success: true, message: 'Password reset successful. You can sign in now.' });
} catch (err) {
console.error('Reset password error:', err.message);
res.status(500).json({ success: false, message: 'Password reset failed' });
}
});
// Middleware for role-based access control
function normalizeRole(value) {
return String(value || '').trim().toLowerCase();
}
function requireRoles(roles = [], message = 'Access denied') {
const allowedRoles = new Set(roles.map(normalizeRole));
return (req, res, next) => {
const userRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
if (!allowedRoles.has(userRole)) {
return res.status(403).json({ success: false, message });
}
next();
};
}
const requireAdmin = requireRoles(['admin'], 'Admin access required');
const requireAssetOrAdmin = requireRoles(['asset', 'admin'], 'Asset or Admin access required');
// ==========================================
// API ROUTES - Roles
// ==========================================
// Get all roles
app.get('/api/roles', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName');
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Get role by ID
app.get('/api/roles/:id', async (req, res) => {
try {
const result = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT * FROM Roles WHERE RoleId = @roleId');
if (result.recordset.length > 0) {
res.json({ success: true, data: result.recordset[0] });
} else {
res.status(404).json({ success: false, message: 'Role not found' });
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create new role (Admin only)
app.post('/api/roles', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
const result = await pool.request()
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName)
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES (@roleName, @description);
SELECT SCOPE_IDENTITY() as RoleId
END
ELSE
BEGIN
SELECT RoleId FROM Roles WHERE RoleName = @roleName
END`);
res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update role (Admin only)
app.put('/api/roles/:id', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
await pool.request()
.input('roleId', sql.Int, req.params.id)
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`UPDATE Roles
SET RoleName = @roleName,
Description = @description
WHERE RoleId = @roleId`);
res.json({ success: true, message: 'Role updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete role (Admin only)
app.delete('/api/roles/:id', requireAdmin, async (req, res) => {
try {
// Check if role is in use
const check = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId');
if (check.recordset[0].Count > 0) {
return res.status(400).json({
success: false,
message: 'Cannot delete role - it is assigned to users'
});
}
await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('DELETE FROM Roles WHERE RoleId = @roleId');
res.json({ success: true, message: 'Role deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Users
// ==========================================
// Get all users
app.get('/api/users', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
u.Status, u.CreatedDate, u.LastLogin, u.IsActive, u.EmailVerified, u.EmailVerifiedAt
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
ORDER BY u.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const result = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
if (result.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
res.json({ success: true, data: result.recordset[0] });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/users/me', async (req, res) => {
try {
const userId = getUserIdFromRequest(req);
if (!userId) {
return res.status(401).json({ success: false, message: 'Missing or invalid user id' });
}
const fullname = String(req.body?.fullname || '').trim();
const incomingEmail = String(req.body?.email || '').trim().toLowerCase();
const currentPassword = String(req.body?.currentPassword || '');
const newPassword = String(req.body?.newPassword || '').trim();
if (!fullname) {
return res.status(400).json({ success: false, message: 'Full name is required' });
}
if (!incomingEmail) {
return res.status(400).json({ success: false, message: 'Email is required' });
}
const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(incomingEmail);
if (!isEmailFormatValid) {
return res.status(400).json({ success: false, message: 'Email format is invalid' });
}
const userResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, Password, Role, RoleId
FROM Users
WHERE UserId = @userId`);
if (userResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'User not found' });
}
const existingUser = userResult.recordset[0];
const emailChanged = String(existingUser.Email || '').toLowerCase() !== incomingEmail;
const shouldChangePassword = newPassword.length > 0;
if (shouldChangePassword) {
if (!currentPassword) {
return res.status(400).json({ success: false, message: 'Current password is required to set a new password' });
}
const isCurrentPasswordValid = await verifyPassword(currentPassword, existingUser.Password);
if (!isCurrentPasswordValid) {
return res.status(400).json({ success: false, message: 'Current password is incorrect' });
}
}
if (emailChanged) {
const duplicateEmailResult = await pool.request()
.input('email', sql.NVarChar, incomingEmail)
.input('userId', sql.Int, userId)
.query('SELECT TOP 1 UserId FROM Users WHERE Email = @email AND UserId <> @userId');
if (duplicateEmailResult.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Email already exists' });
}
}
const request = pool.request()
.input('userId', sql.Int, userId)
.input('fullname', sql.NVarChar, fullname)
.input('email', sql.NVarChar, incomingEmail);
let token;
if (emailChanged) {
const tokenResult = generateEmailVerificationToken();
token = tokenResult.token;
request.input('emailVerifyToken', sql.NVarChar, tokenResult.tokenHash);
request.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES);
}
if (shouldChangePassword) {
const hashedPassword = await hashPassword(newPassword);
const viewPassword = encryptPasswordForView(newPassword);
request.input('password', sql.NVarChar, hashedPassword);
request.input('viewPassword', sql.NVarChar, viewPassword);
}
await request.query(`UPDATE Users
SET FullName = @fullname,
Email = @email
${shouldChangePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
${emailChanged ? ', EmailVerified = 0, EmailVerifiedAt = NULL, EmailVerifyToken = @emailVerifyToken, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())' : ''}
WHERE UserId = @userId`);
let emailResult = { sent: true };
if (emailChanged) {
emailResult = await sendVerificationEmail({
email: incomingEmail,
username: existingUser.Username,
token
});
}
const safeResult = await pool.request()
.input('userId', sql.Int, userId)
.query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive,
CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt
FROM Users
WHERE UserId = @userId`);
const payload = {
success: true,
message: emailChanged
? 'Profile updated. Please confirm your new email address.'
: (shouldChangePassword ? 'Profile and password updated successfully' : 'Profile updated successfully'),
user: safeResult.recordset[0],
verificationRequired: emailChanged,
emailSent: emailChanged ? emailResult.sent : undefined
};
if (emailChanged && emailResult.previewUrl) {
payload.verificationPreviewUrl = emailResult.previewUrl;
}
if (emailChanged && emailResult.reason && !emailResult.sent) {
payload.emailError = emailResult.reason;
}
res.json(payload);
} catch (err) {
console.error('Update profile error:', err.message);
res.status(500).json({ success: false, message: 'Cannot update profile right now' });
}
});
// Get user by ID
app.get('/api/users/:id', requireAdmin, async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.params.id)
.query(`SELECT u.*, r.RoleName FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
WHERE u.UserId = @userId`);
if (result.recordset.length > 0) {
const record = result.recordset[0];
const viewPassword = decryptPasswordForView(record.ViewPassword || '');
const fallbackPlainPassword = !isBcryptHash(record.Password) ? String(record.Password || '') : '';
const plainPassword = viewPassword || fallbackPlainPassword;
const userDetails = {
...record,
Password: plainPassword,
PasswordAvailable: Boolean(plainPassword)
};
res.json({ success: true, data: userDetails });
} else {
res.status(404).json({ success: false, message: 'User not found' });
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create new user (Admin only)
app.post('/api/users', requireAdmin, async (req, res) => {
try {
const { username, password, email, fullname, roleId } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password are required' });
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
const finalRoleId = roleId || 2; // Default to Guest role
// Get role name from Roles table
const roleResult = await pool.request()
.input('roleId', sql.Int, finalRoleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest';
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('roleId', sql.Int, finalRoleId)
.input('role', sql.NVarChar, roleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
BEGIN
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, IsActive)
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 1);
SELECT SCOPE_IDENTITY() as UserId
END
ELSE
BEGIN
SELECT NULL as UserId
END`);
if (result.recordset[0].UserId) {
res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId });
} else {
res.status(400).json({ success: false, message: 'Username already exists' });
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update user (Admin only)
app.put('/api/users/:id', requireAdmin, async (req, res) => {
try {
const { email, fullname, roleId, status, isActive, password } = req.body;
const nextPassword = typeof password === 'string' ? password.trim() : '';
const shouldUpdatePassword = nextPassword.length > 0;
// Get role name from Roles table
let roleName = '';
if (roleId) {
const roleResult = await pool.request()
.input('roleId', sql.Int, roleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : '';
}
const request = pool.request()
.input('userId', sql.Int, req.params.id)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('roleId', sql.Int, roleId)
.input('role', sql.NVarChar, roleName)
.input('status', sql.NVarChar, status)
.input('isActive', sql.Bit, isActive);
if (shouldUpdatePassword) {
const hashedPassword = await hashPassword(nextPassword);
const viewPassword = encryptPasswordForView(nextPassword);
request.input('password', sql.NVarChar, hashedPassword);
request.input('viewPassword', sql.NVarChar, viewPassword);
}
await request.query(`UPDATE Users
SET Email = @email,
FullName = @fullname,
RoleId = @roleId,
Role = @role,
Status = @status,
IsActive = @isActive
${shouldUpdatePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
WHERE UserId = @userId`);
res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete user (Admin only)
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
try {
// Prevent deleting the current admin user (ID = 1)
if (req.params.id === '1') {
return res.status(400).json({
success: false,
message: 'Cannot delete the primary admin user'
});
}
// Delete associated accounts first
await pool.request()
.input('userId', sql.Int, req.params.id)
.query('DELETE FROM Accounts WHERE UserId = @userId');
// Then delete the user
await pool.request()
.input('userId', sql.Int, req.params.id)
.query('DELETE FROM Users WHERE UserId = @userId');
res.json({ success: true, message: 'User deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Applications
// ==========================================
// Get all applications
app.get('/api/applications', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT AppId, Name, Type, Status, Icon, Description, Url, CreatedDate, UpdatedDate FROM Applications ORDER BY Name');
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create application
app.post('/api/applications', async (req, res) => {
try {
const { name, type, status, icon, description, url } = req.body;
const result = await pool.request()
.input('name', sql.NVarChar, name)
.input('type', sql.NVarChar, type)
.input('status', sql.NVarChar, status)
.input('icon', sql.NVarChar, icon)
.input('description', sql.NVarChar, description)
.input('url', sql.NVarChar, url)
.query(`INSERT INTO Applications (Name, Type, Status, Icon, Description, Url)
VALUES (@name, @type, @status, @icon, @description, @url);
SELECT SCOPE_IDENTITY() as AppId`);
res.json({ success: true, message: 'Application created', appId: result.recordset[0].AppId });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update application
app.put('/api/applications/:id', async (req, res) => {
try {
const { name, type, status, icon, description, url } = req.body;
await pool.request()
.input('appId', sql.Int, req.params.id)
.input('name', sql.NVarChar, name)
.input('type', sql.NVarChar, type)
.input('status', sql.NVarChar, status)
.input('icon', sql.NVarChar, icon)
.input('description', sql.NVarChar, description)
.input('url', sql.NVarChar, url)
.query(`UPDATE Applications
SET Name = @name,
Type = @type,
Status = @status,
Icon = @icon,
Description = @description,
Url = @url,
UpdatedDate = GETDATE()
WHERE AppId = @appId`);
res.json({ success: true, message: 'Application updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete application
app.delete('/api/applications/:id', async (req, res) => {
try {
await pool.request()
.input('appId', sql.Int, req.params.id)
.query('DELETE FROM Applications WHERE AppId = @appId');
res.json({ success: true, message: 'Application deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Accounts
// ==========================================
// Get accounts for a user
app.get('/api/accounts/user/:userId', async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.params.userId)
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
WHERE a.UserId = @userId
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Get all accounts (from all users)
app.get('/api/accounts/all', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create account
app.post('/api/accounts', async (req, res) => {
try {
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
const result = await pool.request()
.input('userId', sql.Int, userId)
.input('appId', sql.Int, appId)
.input('accountUsername', sql.NVarChar, accountUsername)
.input('accountPassword', sql.NVarChar, accountPassword)
.input('email', sql.NVarChar, email)
.input('accessLevel', sql.NVarChar, accessLevel)
.input('notes', sql.NVarChar, notes)
.query(`INSERT INTO Accounts (UserId, AppId, AccountUsername, AccountPassword, Email, AccessLevel, Notes)
VALUES (@userId, @appId, @accountUsername, @accountPassword, @email, @accessLevel, @notes);
SELECT SCOPE_IDENTITY() as AccountId`);
res.json({ success: true, message: 'Account created', accountId: result.recordset[0].AccountId });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update account
app.put('/api/accounts/:id', async (req, res) => {
try {
const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body;
await pool.request()
.input('accountId', sql.Int, req.params.id)
.input('userId', sql.Int, userId)
.input('appId', sql.Int, appId)
.input('accountUsername', sql.NVarChar, accountUsername)
.input('accountPassword', sql.NVarChar, accountPassword)
.input('email', sql.NVarChar, email)
.input('accessLevel', sql.NVarChar, accessLevel)
.input('notes', sql.NVarChar, notes)
.query(`UPDATE Accounts
SET UserId = @userId,
AppId = @appId,
AccountUsername = @accountUsername,
AccountPassword = @accountPassword,
Email = @email,
AccessLevel = @accessLevel,
Notes = @notes,
UpdatedDate = GETDATE()
WHERE AccountId = @accountId`);
res.json({ success: true, message: 'Account updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete account
app.delete('/api/accounts/:id', async (req, res) => {
try {
await pool.request()
.input('accountId', sql.Int, req.params.id)
.query('DELETE FROM Accounts WHERE AccountId = @accountId');
res.json({ success: true, message: 'Account deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Asset Departments
// ==========================================
app.get('/api/asset-departments', async (req, res) => {
try {
await syncAssetDepartmentsFromInventory();
const result = await pool.request().query(`
SELECT
d.DepartmentId,
d.DepartmentName,
d.CreatedDate,
d.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetDepartments d
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName)))
GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate
ORDER BY d.DepartmentName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => {
try {
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const existed = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
const inserted = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.query(`
INSERT INTO AssetDepartments (DepartmentName)
VALUES (@departmentName);
SELECT SCOPE_IDENTITY() AS DepartmentId;
`);
res.json({
success: true,
message: 'Đã thêm phòng ban',
departmentId: inserted.recordset[0]?.DepartmentId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
try {
const departmentId = Number(req.params.id);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' });
}
const departmentName = normalizeDepartmentName(req.body?.departmentName);
if (!departmentName) {
return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const currentDepartment = currentResult.recordset[0];
const currentName = String(currentDepartment.DepartmentName || '').trim();
if (currentName.toLowerCase() === departmentName.toLowerCase()) {
return res.json({ success: true, message: 'Đã cập nhật phòng ban' });
}
const duplicated = await pool.request()
.input('departmentName', sql.NVarChar, departmentName)
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT TOP 1 DepartmentId
FROM AssetDepartments
WHERE DepartmentId <> @departmentId
AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetDepartments
SET DepartmentName = @departmentName,
UpdatedDate = GETDATE()
WHERE DepartmentId = @departmentId
`);
await new sql.Request(transaction)
.input('oldDepartmentName', sql.NVarChar, currentName)
.input('newDepartmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = @newDepartmentName,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName)
`);
await transaction.commit();
res.json({ success: true, message: 'Đã cập nhật phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) {
return res.status(409).json({ success: false, message: 'Phong ban da ton tai' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => {
try {
const departmentId = Number(req.params.id);
if (!Number.isInteger(departmentId) || departmentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' });
}
await syncAssetDepartmentsFromInventory();
const currentResult = await pool.request()
.input('departmentId', sql.Int, departmentId)
.query(`
SELECT DepartmentId, DepartmentName
FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' });
}
const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('departmentName', sql.NVarChar, departmentName)
.query(`
UPDATE AssetInventory
SET Department = NULL,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName)
`);
await new sql.Request(transaction)
.input('departmentId', sql.Int, departmentId)
.query(`
DELETE FROM AssetDepartments
WHERE DepartmentId = @departmentId
`);
await transaction.commit();
res.json({ success: true, message: 'Đã xóa phòng ban' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Asset Projects
// ==========================================
app.get('/api/asset-projects', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT
p.ProjectId,
p.ProjectName,
p.CreatedDate,
p.UpdatedDate,
COUNT(ai.AssetId) AS AssetCount
FROM AssetProjects p
LEFT JOIN AssetInventory ai
ON LOWER(LTRIM(RTRIM(ai.Project))) = LOWER(LTRIM(RTRIM(p.ProjectName)))
GROUP BY p.ProjectId, p.ProjectName, p.CreatedDate, p.UpdatedDate
ORDER BY p.ProjectName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-projects', requireAssetOrAdmin, async (req, res) => {
try {
const projectName = normalizeProjectName(req.body?.projectName);
if (!projectName) {
return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' });
}
const existed = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.query(`
SELECT TOP 1 ProjectId
FROM AssetProjects
WHERE LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName)
`);
if (existed.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
const inserted = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.query(`
INSERT INTO AssetProjects (ProjectName)
VALUES (@projectName);
SELECT SCOPE_IDENTITY() AS ProjectId;
`);
res.json({
success: true,
message: 'Da them du an',
projectId: inserted.recordset[0]?.ProjectId
});
} catch (err) {
if (String(err.message || '').includes('UX_AssetProjects_ProjectName')) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => {
try {
const projectId = Number(req.params.id);
if (!Number.isInteger(projectId) || projectId <= 0) {
return res.status(400).json({ success: false, message: 'Ma du an khong hop le' });
}
const projectName = normalizeProjectName(req.body?.projectName);
if (!projectName) {
return res.status(400).json({ success: false, message: 'Ten du an la bat buoc' });
}
const currentResult = await pool.request()
.input('projectId', sql.Int, projectId)
.query(`
SELECT ProjectId, ProjectName
FROM AssetProjects
WHERE ProjectId = @projectId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Khong tim thay du an' });
}
const currentProject = currentResult.recordset[0];
const currentName = String(currentProject.ProjectName || '').trim();
if (currentName.toLowerCase() === projectName.toLowerCase()) {
return res.json({ success: true, message: 'Da cap nhat du an' });
}
const duplicated = await pool.request()
.input('projectName', sql.NVarChar, projectName)
.input('projectId', sql.Int, projectId)
.query(`
SELECT TOP 1 ProjectId
FROM AssetProjects
WHERE ProjectId <> @projectId
AND LOWER(LTRIM(RTRIM(ProjectName))) = LOWER(@projectName)
`);
if (duplicated.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('projectId', sql.Int, projectId)
.input('projectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetProjects
SET ProjectName = @projectName,
UpdatedDate = GETDATE()
WHERE ProjectId = @projectId
`);
await new sql.Request(transaction)
.input('oldProjectName', sql.NVarChar, currentName)
.input('newProjectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetInventory
SET Project = @newProjectName,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@oldProjectName)
`);
await transaction.commit();
res.json({ success: true, message: 'Da cap nhat du an' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
if (String(err.message || '').includes('UX_AssetProjects_ProjectName')) {
return res.status(409).json({ success: false, message: 'Du an da ton tai' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/asset-projects/:id', requireAssetOrAdmin, async (req, res) => {
try {
const projectId = Number(req.params.id);
if (!Number.isInteger(projectId) || projectId <= 0) {
return res.status(400).json({ success: false, message: 'Ma du an khong hop le' });
}
const currentResult = await pool.request()
.input('projectId', sql.Int, projectId)
.query(`
SELECT ProjectId, ProjectName
FROM AssetProjects
WHERE ProjectId = @projectId
`);
if (currentResult.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Khong tim thay du an' });
}
const projectName = String(currentResult.recordset[0].ProjectName || '').trim();
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
await new sql.Request(transaction)
.input('projectName', sql.NVarChar, projectName)
.query(`
UPDATE AssetInventory
SET Project = NULL,
UpdatedDate = GETDATE()
WHERE LOWER(LTRIM(RTRIM(Project))) = LOWER(@projectName)
`);
await new sql.Request(transaction)
.input('projectId', sql.Int, projectId)
.query(`
DELETE FROM AssetProjects
WHERE ProjectId = @projectId
`);
await transaction.commit();
res.json({ success: true, message: 'Da xoa du an' });
} catch (transactionErr) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction already ended.
}
throw transactionErr;
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Asset Inventory
// ==========================================
app.get('/api/asset-borrows', async (req, res) => {
try {
const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
const requesterId = getUserIdFromRequest(req);
const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset';
const request = pool.request();
if (!canManageRequests) {
request.input('requesterId', sql.Int, requesterId || -1);
}
const result = await request.query(`
SELECT
br.BorrowId,
br.AssetId,
ai.AssetCode,
ai.AssetName,
br.RequestType,
br.RequestStatus,
br.BorrowerName,
br.BorrowQuantity,
COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit,
br.BorrowDate,
br.RequestNote,
br.RejectReason,
br.CreatedBy,
br.ProcessedBy,
br.ProcessedByName,
br.ProcessedDate,
br.CreatedDate
FROM AssetBorrowRequests br
LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId
${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'}
ORDER BY br.CreatedDate DESC, br.BorrowId DESC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-borrows', async (req, res) => {
try {
const createdBy = getUserIdFromRequest(req);
const actorName = await getUserDisplayNameById(createdBy);
const assetId = Number(req.body?.assetId);
const requestType = normalizeAssetRequestType(req.body?.requestType);
const borrowQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const requestedBorrowDate = parseNullableDate(req.body?.borrowDate);
const borrowDate = requestedBorrowDate || new Date();
const borrowerName = String(actorName || req.body?.borrowerName || '').trim();
const requestNote = String(req.body?.note || '').trim() || null;
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Tai san khong hop le' });
}
if (!borrowerName) {
return res.status(400).json({ success: false, message: 'Khong xac dinh duoc nguoi tao don' });
}
if (borrowQuantity <= 0) {
return res.status(400).json({ success: false, message: 'So luong phai lon hon 0' });
}
const assetResult = await pool.request()
.input('assetId', sql.Int, assetId)
.query(`
SELECT TOP 1
AssetId,
AssetCode,
AssetName,
Quantity,
ImportInPeriod,
Borrower,
Unit
FROM AssetInventory
WHERE AssetId = @assetId
`);
const asset = assetResult.recordset?.[0];
if (!asset) {
return res.status(404).json({ success: false, message: 'Khong tim thay tai san' });
}
const currentBorrowedEntries = parseBorrowerEntries(asset.Borrower);
const currentBorrowed = currentBorrowedEntries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const endingBalance = Math.max(
parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed,
0
);
const unit = String(req.body?.unit || '').trim() || String(asset.Unit || '').trim() || null;
if (requestType === 'borrow') {
if (endingBalance <= 0) {
return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky' });
}
if (borrowQuantity > endingBalance) {
return res.status(400).json({
success: false,
message: `So luong muon (${borrowQuantity}) vuot qua ton cuoi ky (${endingBalance})`
});
}
} else {
const existed = currentBorrowedEntries.find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase());
const borrowedQuantity = parseNonNegativeInteger(existed?.quantity, 0);
if (borrowedQuantity <= 0) {
return res.status(400).json({
success: false,
message: 'Ban chua co du lieu muon tai san nay de tao don tra'
});
}
if (borrowQuantity > borrowedQuantity) {
return res.status(400).json({
success: false,
message: `So luong tra (${borrowQuantity}) vuot qua so luong dang muon (${borrowedQuantity})`
});
}
}
const insertResult = await pool.request()
.input('assetId', sql.Int, assetId)
.input('requestType', sql.NVarChar, requestType)
.input('requestStatus', sql.NVarChar, 'pending')
.input('borrowerName', sql.NVarChar, borrowerName)
.input('borrowQuantity', sql.Int, borrowQuantity)
.input('unit', sql.NVarChar, unit)
.input('borrowDate', sql.Date, borrowDate)
.input('requestNote', sql.NVarChar, requestNote)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetBorrowRequests (
AssetId,
RequestType,
RequestStatus,
BorrowerName,
BorrowQuantity,
Unit,
BorrowDate,
RequestNote,
CreatedBy
) VALUES (
@assetId,
@requestType,
@requestStatus,
@borrowerName,
@borrowQuantity,
@unit,
@borrowDate,
@requestNote,
@createdBy
);
SELECT SCOPE_IDENTITY() AS BorrowId;
`);
res.json({
success: true,
message: requestType === 'return'
? 'Tao don tra tai san thanh cong. Don dang cho xu ly.'
: 'Tao don muon tai san thanh cong. Don dang cho xu ly.',
data: {
borrowId: insertResult.recordset?.[0]?.BorrowId || null
}
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => {
const transaction = new sql.Transaction(pool);
try {
const borrowId = Number(req.params.id);
const action = normalizeAssetRequestStatus(req.body?.action);
const rejectReason = String(req.body?.rejectReason || '').trim() || null;
const processedBy = getUserIdFromRequest(req);
const processorName = String(
await getUserDisplayNameById(processedBy)
|| req.headers['x-user-role']
|| 'Asset/Admin'
).trim();
if (!Number.isInteger(borrowId) || borrowId <= 0) {
return res.status(400).json({ success: false, message: 'Ma don khong hop le' });
}
if (!['approved', 'rejected'].includes(action)) {
return res.status(400).json({ success: false, message: 'Hanh dong khong hop le' });
}
if (action === 'rejected' && !rejectReason) {
return res.status(400).json({ success: false, message: 'Vui long nhap ly do tu choi' });
}
await transaction.begin();
const requestResult = await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowId)
.query(`
SELECT TOP 1
br.BorrowId,
br.AssetId,
br.RequestType,
br.RequestStatus,
br.BorrowerName,
br.BorrowQuantity,
br.BorrowDate,
br.Unit,
ai.AssetCode,
ai.AssetName,
ai.Quantity,
ai.ImportInPeriod,
ai.Borrower,
ai.Unit AS AssetUnit
FROM AssetBorrowRequests br
INNER JOIN AssetInventory ai ON ai.AssetId = br.AssetId
WHERE br.BorrowId = @borrowId
`);
const targetRequest = requestResult.recordset?.[0];
if (!targetRequest) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Khong tim thay don can xu ly' });
}
const currentStatus = normalizeAssetRequestStatus(targetRequest.RequestStatus);
if (currentStatus !== 'pending') {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Don nay da duoc xu ly truoc do' });
}
if (action === 'approved') {
const requestType = normalizeAssetRequestType(targetRequest.RequestType);
const borrowerName = String(targetRequest.BorrowerName || '').trim();
const requestQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0);
if (requestType === 'borrow') {
const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const endingBalance = Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- currentBorrowed,
0
);
if (requestQuantity > endingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Khong du ton kho de duyet. Ton hien tai: ${endingBalance}`
});
}
const mergedBorrowerSummary = mergeBorrowerEntries(
targetRequest.Borrower,
borrowerName,
requestQuantity
);
if (mergedBorrowerSummary && mergedBorrowerSummary.length > 255) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Thong tin nguoi muon qua dai, vui long tu choi don va yeu cau nguoi dung dieu chinh.'
});
}
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary)
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
} else {
const decreased = decreaseBorrowerEntries(
targetRequest.Borrower,
borrowerName,
requestQuantity
);
if (!decreased.success) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Không thể duyệt trả tài sản: số lượng trả không hợp lệ hoặc không còn người mượn. Bạn có thể xóa đơn chờ này.'
});
}
const borrowerSummary = decreased.summary || null;
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, borrowerSummary)
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
}
}
await new sql.Request(transaction)
.input('borrowId', sql.Int, borrowId)
.input('requestStatus', sql.NVarChar, action)
.input('rejectReason', sql.NVarChar, action === 'rejected' ? rejectReason : null)
.input('processedBy', sql.Int, processedBy)
.input('processedByName', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetBorrowRequests
SET RequestStatus = @requestStatus,
RejectReason = @rejectReason,
ProcessedBy = @processedBy,
ProcessedByName = @processedByName,
ProcessedDate = GETDATE(),
UpdatedDate = GETDATE()
WHERE BorrowId = @borrowId
`);
await transaction.commit();
return res.json({
success: true,
message: action === 'approved'
? 'Da duyet don thanh cong'
: 'Da tu choi don'
});
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors when transaction already finished.
}
return res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/asset-borrows/:id', async (req, res) => {
try {
const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole);
const requesterId = getUserIdFromRequest(req);
const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset';
const borrowId = Number(req.params.id);
if (!Number.isInteger(borrowId) || borrowId <= 0) {
return res.status(400).json({ success: false, message: 'Ma don khong hop le' });
}
if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) {
return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' });
}
const deleteResult = await pool.request()
.input('borrowId', sql.Int, borrowId)
.input('requesterId', sql.Int, requesterId || -1)
.query(`
DELETE FROM AssetBorrowRequests
OUTPUT DELETED.BorrowId
WHERE BorrowId = @borrowId
AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending'
${canManageRequests ? '' : 'AND CreatedBy = @requesterId'}
`);
if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) {
return res.json({ success: true, message: 'Da huy don cho' });
}
const existed = await pool.request()
.input('borrowId', sql.Int, borrowId)
.query(`
SELECT TOP 1 BorrowId, RequestStatus, CreatedBy
FROM AssetBorrowRequests
WHERE BorrowId = @borrowId
`);
const row = existed.recordset?.[0];
if (!row) {
return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' });
}
if (!canManageRequests && Number(row.CreatedBy) !== requesterId) {
return res.status(403).json({
success: false,
message: 'Ban chi duoc huy don do chinh minh tao'
});
}
const currentStatus = normalizeAssetRequestStatus(row.RequestStatus);
if (currentStatus !== 'pending') {
return res.status(400).json({
success: false,
message: 'Chi duoc huy don o trang thai cho xu ly'
});
}
return res.status(400).json({
success: false,
message: 'Khong the huy don vao luc nay'
});
} catch (err) {
return res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/assets', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy, CreatedDate, UpdatedDate
FROM AssetInventory
ORDER BY UpdatedDate DESC, AssetName ASC
`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/assets/search', async (req, res) => {
try {
const rawKeyword = String(req.query.q || '').trim();
const keywordLike = `%${rawKeyword}%`;
const limit = Math.min(parsePositiveInteger(req.query.limit, 80), 200);
const offset = parseNonNegativeInteger(req.query.offset, 0);
const result = await pool.request()
.input('limit', sql.Int, limit)
.input('offset', sql.Int, offset)
.input('keyword', sql.NVarChar, rawKeyword)
.input('keywordLike', sql.NVarChar, keywordLike)
.query(`
;WITH FilteredAssets AS (
SELECT
AssetId,
AssetCode,
AssetName,
Unit,
UpdatedDate,
CASE WHEN @keyword <> '' AND AssetCode LIKE @keywordLike THEN 0 ELSE 1 END AS CodeRank,
CASE WHEN @keyword <> '' AND AssetName LIKE @keywordLike THEN 0 ELSE 1 END AS NameRank
FROM AssetInventory
WHERE @keyword = ''
OR AssetCode LIKE @keywordLike
OR AssetName LIKE @keywordLike
OR Model LIKE @keywordLike
),
OrderedAssets AS (
SELECT
AssetId,
AssetCode,
AssetName,
Unit,
ROW_NUMBER() OVER (
ORDER BY
CodeRank ASC,
NameRank ASC,
UpdatedDate DESC,
AssetName ASC
) AS RowNum
FROM FilteredAssets
)
SELECT AssetId, AssetCode, AssetName, Unit
FROM OrderedAssets
WHERE RowNum > @offset AND RowNum <= (@offset + @limit)
ORDER BY RowNum;
SELECT COUNT(*) AS TotalCount
FROM AssetInventory
WHERE @keyword = ''
OR AssetCode LIKE @keywordLike
OR AssetName LIKE @keywordLike
OR Model LIKE @keywordLike;
`);
const rows = Array.isArray(result.recordsets?.[0]) ? result.recordsets[0] : [];
const totalCount = Number(result.recordsets?.[1]?.[0]?.TotalCount) || 0;
const hasMore = offset + rows.length < totalCount;
res.json({ success: true, data: rows, hasMore, total: totalCount });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.get('/api/assets/:id', async (req, res) => {
try {
const result = await pool.request()
.input('assetId', sql.Int, req.params.id)
.query(`
SELECT AssetId, AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy, CreatedDate, UpdatedDate
FROM AssetInventory
WHERE AssetId = @assetId
`);
if (result.recordset.length === 0) {
return res.status(404).json({ success: false, message: 'Asset not found' });
}
res.json({ success: true, data: result.recordset[0] });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
if (!payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset name is required' });
}
if (!payload.assetCode) {
payload.assetCode = await generateUniqueManualAssetCode(payload);
}
await ensureDepartmentExists(payload.department);
const result = await pool.request()
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('borrower', sql.NVarChar, payload.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
) VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
SELECT SCOPE_IDENTITY() AS AssetId;
`);
res.json({ success: true, message: 'Asset created', assetId: result.recordset[0].AssetId });
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Asset code already exists' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
const updatedBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(updatedBy);
if (!payload.assetCode || !payload.assetName) {
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
}
await ensureDepartmentExists(payload.department);
await pool.request()
.input('assetId', sql.Int, req.params.id)
.input('assetCode', sql.NVarChar, payload.assetCode)
.input('assetName', sql.NVarChar, payload.assetName)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('importInPeriod', sql.Int, payload.importInPeriod)
.input('exportInPeriod', sql.Int, payload.exportInPeriod)
.input('endingBalance', sql.Int, payload.endingBalance)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('project', sql.NVarChar, payload.project)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('borrower', sql.NVarChar, payload.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.query(`
UPDATE AssetInventory
SET AssetCode = @assetCode,
AssetName = @assetName,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
Unit = @unit,
Department = @department,
Project = @project,
Location = @location,
Custodian = @custodian,
Borrower = @borrower,
ExportedBy = @exportedBy,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
Status = @status,
Notes = @notes,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
res.json({ success: true, message: 'Asset updated' });
} catch (err) {
if (String(err.message || '').includes('UNIQUE')) {
return res.status(409).json({ success: false, message: 'Asset code already exists' });
}
res.status(500).json({ success: false, message: err.message });
}
});
app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => {
try {
await pool.request()
.input('assetId', sql.Int, req.params.id)
.query('DELETE FROM AssetInventory WHERE AssetId = @assetId');
res.json({ success: true, message: 'Asset deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async (req, res) => {
let incomingRows = [];
let source = 'rows';
let parseDiagnostics = [];
try {
if (req.file?.buffer) {
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
if (!workbook.SheetNames?.length) {
return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' });
}
const parsed = parseAssetImportRowsFromWorkbook(workbook);
incomingRows = parsed.rows;
parseDiagnostics = parsed.diagnostics;
source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file';
} else {
incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : [];
}
} catch (err) {
return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` });
}
if (!incomingRows.length) {
if (req.file) {
console.warn('Asset import parse returned 0 rows', {
diagnostics: parseDiagnostics
});
}
return res.status(400).json({
success: false,
message: req.file
? 'Khong tim thay dong du lieu hop le trong file Excel. Vui long kiem tra dong STT va du lieu cot ten/model/ton cuoi ky.'
: 'Import data is empty',
diagnostics: req.file ? parseDiagnostics : undefined
});
}
const createdBy = getUserIdFromRequest(req);
const exportedBy = await getUserDisplayNameById(createdBy);
const normalizedRows = incomingRows
.map((row, rowIndex) => {
const normalized = normalizeAssetPayload(row);
const hasOriginalAssetCode = String(normalized.assetCode || '').trim() !== '';
if (!hasOriginalAssetCode && normalized.assetName) {
normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1);
}
normalized.__hasOriginalAssetCode = hasOriginalAssetCode;
return normalized;
})
.filter(row => !isHeaderLikeAssetImportRow(row))
.filter(row => isMeaningfulImportedAssetRow(row));
if (!normalizedRows.length) {
return res.status(400).json({ success: false, message: 'No valid rows found. Asset name or model is required.' });
}
const transaction = new sql.Transaction(pool);
let inserted = 0;
let updated = 0;
try {
await transaction.begin();
for (const row of normalizedRows) {
if (!row.__hasOriginalAssetCode) {
const uniqueImportCode = await ensureUniqueImportAssetCode(transaction, row.assetCode);
row.assetCode = uniqueImportCode;
await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, row.assetCode)
.input('assetName', sql.NVarChar, row.assetName)
.input('model', sql.NVarChar, row.model)
.input('serialNumber', sql.NVarChar, row.serialNumber)
.input('quantity', sql.Int, row.quantity)
.input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance)
.input('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project)
.input('location', sql.NVarChar, row.location)
.input('custodian', sql.NVarChar, row.custodian)
.input('borrower', sql.NVarChar, row.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, row.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice)
.input('status', sql.NVarChar, row.status)
.input('notes', sql.NVarChar, row.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
)
VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
);
`);
inserted += 1;
continue;
}
const mergeResult = await new sql.Request(transaction)
.input('assetCode', sql.NVarChar, row.assetCode)
.input('assetName', sql.NVarChar, row.assetName)
.input('model', sql.NVarChar, row.model)
.input('serialNumber', sql.NVarChar, row.serialNumber)
.input('quantity', sql.Int, row.quantity)
.input('importInPeriod', sql.Int, row.importInPeriod)
.input('exportInPeriod', sql.Int, row.exportInPeriod)
.input('endingBalance', sql.Int, row.endingBalance)
.input('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department)
.input('project', sql.NVarChar, row.project)
.input('location', sql.NVarChar, row.location)
.input('custodian', sql.NVarChar, row.custodian)
.input('borrower', sql.NVarChar, row.borrower)
.input('exportedBy', sql.NVarChar, exportedBy)
.input('purchaseDate', sql.Date, row.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice)
.input('status', sql.NVarChar, row.status)
.input('notes', sql.NVarChar, row.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
MERGE AssetInventory AS target
USING (SELECT @assetCode AS AssetCode) AS source
ON target.AssetCode = source.AssetCode
WHEN MATCHED THEN
UPDATE SET
AssetName = @assetName,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
ImportInPeriod = @importInPeriod,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
Unit = @unit,
Department = @department,
Project = @project,
Location = @location,
Custodian = @custodian,
Borrower = @borrower,
ExportedBy = @exportedBy,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
Status = @status,
Notes = @notes,
UpdatedDate = GETDATE()
WHEN NOT MATCHED THEN
INSERT (
AssetCode, AssetName, Model, SerialNumber,
Quantity, ImportInPeriod, ExportInPeriod, EndingBalance,
Unit, Department, Project, Location, Custodian, Borrower, ExportedBy,
PurchaseDate, PurchasePrice, Status, Notes, CreatedBy
)
VALUES (
@assetCode, @assetName, @model, @serialNumber,
@quantity, @importInPeriod, @exportInPeriod, @endingBalance,
@unit, @department, @project, @location, @custodian, @borrower, @exportedBy,
@purchaseDate, @purchasePrice, @status, @notes, @createdBy
)
OUTPUT $action AS MergeAction;
`);
const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase();
if (mergeAction === 'INSERT') inserted += 1;
if (mergeAction === 'UPDATE') updated += 1;
}
await transaction.commit();
await syncAssetDepartmentsFromInventory();
res.json({
success: true,
message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`,
data: {
source,
totalReceived: incomingRows.length,
processed: normalizedRows.length,
inserted,
updated
}
});
} catch (err) {
try {
await transaction.rollback();
} catch (rollbackErr) {
// Ignore rollback errors if transaction is already completed.
}
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Database Info
// ==========================================
// Get database information
app.get('/api/database/info', async (req, res) => {
try {
const tables = await pool.request().query(`
SELECT TABLE_NAME as TableName,
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = t.TABLE_NAME) as ColumnCount
FROM INFORMATION_SCHEMA.TABLES t
WHERE TABLE_SCHEMA = 'dbo'
ORDER BY TABLE_NAME
`);
const users = await pool.request().query('SELECT COUNT(*) as Count FROM Users');
const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications');
const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts');
const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory');
res.json({
success: true,
database: 'AccManager',
server: '172.20.235.176',
tables: tables.recordset,
statistics: {
users: users.recordset[0].Count,
applications: apps.recordset[0].Count,
accounts: accounts.recordset[0].Count,
assets: assets.recordset[0].Count
}
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', database: 'Connected' });
});
// ==========================================
// Error Handling
// ==========================================
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ success: false, message: err.message });
});
// ==========================================
// Server Startup
// ==========================================
async function startServer() {
try {
await initializeDatabase();
app.listen(PORT, () => {
console.log(`\n========================================`);
console.log(`AccManager Backend Server`);
console.log(`========================================`);
console.log(`[OK] Server running on http://localhost:${PORT}`);
console.log('[OK] Database: AccManager');
console.log('[OK] Default admin: admin / admin');
console.log(`\nAPI Endpoints:`);
console.log(` POST /api/auth/login`);
console.log(` GET /api/database/info`);
console.log(` GET /api/users`);
console.log(` GET /api/applications`);
console.log(` GET /api/accounts/user/:userId`);
console.log(` GET /api/assets`);
console.log(`========================================\n`);
});
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...');
if (pool) {
await pool.close();
}
process.exit(0);
});
startServer();