2764 lines
108 KiB
JavaScript
2764 lines
108 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);
|
|
|
|
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 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
|
|
};
|
|
}
|
|
}
|
|
|
|
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 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 = Number(value);
|
|
if (Number.isInteger(parsed) && parsed >= 0) {
|
|
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', 'đang sử dụng', 'active'].includes(normalized)) {
|
|
return 'in_use';
|
|
}
|
|
|
|
if (['maintenance', 'bao tri', 'bảo trì'].includes(normalized)) {
|
|
return 'maintenance';
|
|
}
|
|
|
|
if (['disposed', 'thanh ly', 'thanh lý', 'retired'].includes(normalized)) {
|
|
return 'disposed';
|
|
}
|
|
|
|
if (['in_stock', 'in stock', 'ton kho', 'tồn kho', 'warehouse'].includes(normalized)) {
|
|
return 'in_stock';
|
|
}
|
|
|
|
return 'in_use';
|
|
}
|
|
|
|
function normalizeAssetPayload(payload = {}) {
|
|
const quantity = parseNonNegativeInteger(payload.quantity, 0);
|
|
const endingBalance = parseNonNegativeInteger(payload.endingBalance, quantity);
|
|
|
|
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: parseNonNegativeInteger(payload.importInPeriod, 0),
|
|
exportInPeriod: parseNonNegativeInteger(payload.exportInPeriod, 0),
|
|
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 normalizeImportToken(value) {
|
|
return String(value || '')
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[đĐ]/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;
|
|
}
|
|
|
|
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 = {
|
|
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 => row.assetCode && row.assetName);
|
|
|
|
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 = 1) {
|
|
if (value === undefined || value === null || value === '') {
|
|
return fallback;
|
|
}
|
|
|
|
const normalized = String(value).trim().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(/[đĐ]/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 suffix = String(rowNumber || 0).padStart(4, '0');
|
|
return `IMP-${base}-${suffix}`;
|
|
}
|
|
|
|
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 = {
|
|
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 => row.assetCode && row.assetName);
|
|
}
|
|
|
|
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 endingBalance = parseAssetImportNumericValue(row[detectedSttCol + 9] ?? '', 0);
|
|
const mapped = {
|
|
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 => row.assetCode && row.assetName);
|
|
}
|
|
|
|
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 endingBalance = parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0);
|
|
const mapped = {
|
|
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 => row.assetCode && row.assetName);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function parseAssetImportRowsFromWorkbook(workbook) {
|
|
const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : [];
|
|
let bestRows = [];
|
|
let bestSheetName = '';
|
|
let bestNonEmptyRows = 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);
|
|
diagnostics.push({
|
|
sheetName,
|
|
parsedRows: parsedRows.length,
|
|
nonEmptyRows
|
|
});
|
|
|
|
if (parsedRows.length > bestRows.length || (parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)) {
|
|
bestRows = parsedRows;
|
|
bestSheetName = sheetName;
|
|
bestNonEmptyRows = nonEmptyRows;
|
|
}
|
|
}
|
|
|
|
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('✓ 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('✓ 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(`✓ 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`,
|
|
|
|
// 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);`);
|
|
} catch (err) {
|
|
console.error('AssetInventory 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(`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(`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);
|
|
}
|
|
|
|
// 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('✓ 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('✓ 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' });
|
|
}
|
|
});
|
|
|
|
// 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 Inventory
|
|
// ==========================================
|
|
|
|
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/: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.assetCode || !payload.assetName) {
|
|
return res.status(400).json({ success: false, message: 'Asset code and asset name are required' });
|
|
}
|
|
|
|
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 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 => normalizeAssetPayload(row))
|
|
.filter(row => !isHeaderLikeAssetImportRow(row))
|
|
.filter(row => row.assetCode && row.assetName);
|
|
|
|
if (!normalizedRows.length) {
|
|
return res.status(400).json({ success: false, message: 'No valid rows found. Asset code and name are required.' });
|
|
}
|
|
|
|
const transaction = new sql.Transaction(pool);
|
|
let inserted = 0;
|
|
let updated = 0;
|
|
|
|
try {
|
|
await transaction.begin();
|
|
|
|
for (const row of normalizedRows) {
|
|
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;
|
|
} else if (mergeAction === 'UPDATE') {
|
|
updated += 1;
|
|
}
|
|
}
|
|
|
|
await transaction.commit();
|
|
|
|
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(`✓ Server running on http://localhost:${PORT}`);
|
|
console.log(`✓ Database: AccManager`);
|
|
console.log(`✓ 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();
|