Files
ManagerAccount/backend/server.js
2026-04-22 10:09:37 +07:00

2632 lines
101 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;
}
function parsePositiveInteger(value, fallback = 1) {
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 = {}) {
return {
assetCode: String(payload.assetCode || '').trim(),
assetName: String(payload.assetName || '').trim(),
category: String(payload.category || '').trim() || null,
brand: String(payload.brand || '').trim() || null,
model: String(payload.model || '').trim() || null,
serialNumber: String(payload.serialNumber || '').trim() || null,
quantity: parsePositiveInteger(payload.quantity, 1),
unit: String(payload.unit || '').trim() || null,
department: String(payload.department || '').trim() || null,
location: String(payload.location || '').trim() || null,
custodian: String(payload.custodian || '').trim() || null,
purchaseDate: parseNullableDate(payload.purchaseDate),
purchasePrice: parseNullableDecimal(payload.purchasePrice),
warrantyUntil: parseNullableDate(payload.warrantyUntil),
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',
'unit',
'location',
'department',
'status',
'notes'
]);
const fields = [
row.assetCode,
row.assetName,
row.model,
row.unit,
row.status,
row.location,
row.department,
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'],
category: ['Category', 'Nhom', 'Loai', 'Loai tai san', 'Nhom vat tu'],
brand: ['Brand', 'Hang', 'Nhan hieu'],
model: ['Model', 'Dong may'],
serialNumber: ['Serial Number', 'Serial', 'So serial', 'So seri'],
quantity: ['Ton cuoi ki', 'Ton cuoi ky', 'Quantity', 'So luong', 'SL', 'Ton dau ky', 'Ton dau ki', 'Nhap trong ky', 'Nhap trong ki', 'Xuat trong ky', 'Xuat trong ki'],
unit: ['Unit', 'Don vi', 'DVT'],
department: ['Department', 'Bo phan', 'Phong ban', 'Du an'],
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'],
warrantyUntil: ['Warranty Until', 'Bao hanh den', 'Han bao hanh'],
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('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('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('toncuoi') || 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.quantity !== undefined) score += 2;
if (fieldMap.unit !== undefined) score += 1;
if (fieldMap.location !== 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 mapped = {
assetCode: String(pick(row, bestFieldMap.assetCode)).trim(),
assetName: String(pick(row, bestFieldMap.assetName)).trim(),
category: String(pick(row, bestFieldMap.category)).trim(),
brand: String(pick(row, bestFieldMap.brand)).trim(),
model: String(pick(row, bestFieldMap.model)).trim(),
serialNumber: String(pick(row, bestFieldMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, bestFieldMap.quantity), 1),
unit: String(pick(row, bestFieldMap.unit)).trim(),
department: String(pick(row, bestFieldMap.department)).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),
warrantyUntil: pick(row, bestFieldMap.warrantyUntil),
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),
category: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.category),
brand: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.brand),
model: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.model),
serialNumber: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.serialNumber),
quantity: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.quantity),
unit: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.unit),
department: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.department),
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),
warrantyUntil: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.warrantyUntil),
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 + 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.department < 0) indexMap.department = 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 mapped = {
assetCode: String(pick(row, indexMap.assetCode)).trim(),
assetName: String(pick(row, indexMap.assetName)).trim(),
category: String(pick(row, indexMap.category)).trim(),
brand: String(pick(row, indexMap.brand)).trim(),
model: String(pick(row, indexMap.model)).trim(),
serialNumber: String(pick(row, indexMap.serialNumber)).trim(),
quantity: parseAssetImportNumericValue(pick(row, indexMap.quantity), 1),
unit: String(pick(row, indexMap.unit)).trim(),
department: String(pick(row, indexMap.department)).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),
warrantyUntil: pick(row, indexMap.warrantyUntil),
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 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(),
category: '',
brand: '',
model: String(row[detectedSttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[detectedSttCol + 9] ?? row[detectedSttCol + 6] ?? '', 1),
unit: String(row[detectedSttCol + 5] ?? '').trim(),
department: String(row[detectedSttCol + 13] ?? '').trim(),
location: String(row[detectedSttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[detectedSttCol + 1] ?? '',
purchasePrice: '',
warrantyUntil: '',
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 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(),
category: '',
brand: '',
model: String(row[sttCol + 4] ?? '').trim(),
serialNumber: '',
quantity: parseAssetImportNumericValue(row[sttCol + 9] ?? row[sttCol + 6] ?? '', 1),
unit: String(row[sttCol + 5] ?? '').trim(),
department: String(row[sttCol + 13] ?? '').trim(),
location: String(row[sttCol + 12] ?? '').trim(),
custodian: '',
purchaseDate: row[sttCol + 1] ?? '',
purchasePrice: '',
warrantyUntil: '',
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,
Category NVARCHAR(100),
Brand NVARCHAR(100),
Model NVARCHAR(255),
SerialNumber NVARCHAR(100),
Quantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50),
Department NVARCHAR(100),
Location NVARCHAR(150),
Custodian NVARCHAR(100),
PurchaseDate DATE NULL,
PurchasePrice DECIMAL(18,2) NULL,
WarrantyUntil DATE 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.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
const requireAdmin = (req, res, next) => {
const userRole = req.headers['x-user-role'] || req.query.userRole;
if (userRole !== 'admin') {
return res.status(403).json({ success: false, message: 'Admin access required' });
}
next();
};
// ==========================================
// 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, Category, Brand, Model, SerialNumber,
Quantity, Unit, Department, Location, Custodian, PurchaseDate,
PurchasePrice, WarrantyUntil, 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, Category, Brand, Model, SerialNumber,
Quantity, Unit, Department, Location, Custodian, PurchaseDate,
PurchasePrice, WarrantyUntil, 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', async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
const createdBy = getUserIdFromRequest(req);
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('category', sql.NVarChar, payload.category)
.input('brand', sql.NVarChar, payload.brand)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('warrantyUntil', sql.Date, payload.warrantyUntil)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.input('createdBy', sql.Int, createdBy)
.query(`
INSERT INTO AssetInventory (
AssetCode, AssetName, Category, Brand, Model, SerialNumber,
Quantity, Unit, Department, Location, Custodian, PurchaseDate,
PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy
) VALUES (
@assetCode, @assetName, @category, @brand, @model, @serialNumber,
@quantity, @unit, @department, @location, @custodian, @purchaseDate,
@purchasePrice, @warrantyUntil, @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', async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);
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('category', sql.NVarChar, payload.category)
.input('brand', sql.NVarChar, payload.brand)
.input('model', sql.NVarChar, payload.model)
.input('serialNumber', sql.NVarChar, payload.serialNumber)
.input('quantity', sql.Int, payload.quantity)
.input('unit', sql.NVarChar, payload.unit)
.input('department', sql.NVarChar, payload.department)
.input('location', sql.NVarChar, payload.location)
.input('custodian', sql.NVarChar, payload.custodian)
.input('purchaseDate', sql.Date, payload.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice)
.input('warrantyUntil', sql.Date, payload.warrantyUntil)
.input('status', sql.NVarChar, payload.status)
.input('notes', sql.NVarChar, payload.notes)
.query(`
UPDATE AssetInventory
SET AssetCode = @assetCode,
AssetName = @assetName,
Category = @category,
Brand = @brand,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
Unit = @unit,
Department = @department,
Location = @location,
Custodian = @custodian,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
WarrantyUntil = @warrantyUntil,
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', 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', 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 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('category', sql.NVarChar, row.category)
.input('brand', sql.NVarChar, row.brand)
.input('model', sql.NVarChar, row.model)
.input('serialNumber', sql.NVarChar, row.serialNumber)
.input('quantity', sql.Int, row.quantity)
.input('unit', sql.NVarChar, row.unit)
.input('department', sql.NVarChar, row.department)
.input('location', sql.NVarChar, row.location)
.input('custodian', sql.NVarChar, row.custodian)
.input('purchaseDate', sql.Date, row.purchaseDate)
.input('purchasePrice', sql.Decimal(18, 2), row.purchasePrice)
.input('warrantyUntil', sql.Date, row.warrantyUntil)
.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,
Category = @category,
Brand = @brand,
Model = @model,
SerialNumber = @serialNumber,
Quantity = @quantity,
Unit = @unit,
Department = @department,
Location = @location,
Custodian = @custodian,
PurchaseDate = @purchaseDate,
PurchasePrice = @purchasePrice,
WarrantyUntil = @warrantyUntil,
Status = @status,
Notes = @notes,
UpdatedDate = GETDATE()
WHEN NOT MATCHED THEN
INSERT (
AssetCode, AssetName, Category, Brand, Model, SerialNumber,
Quantity, Unit, Department, Location, Custodian,
PurchaseDate, PurchasePrice, WarrantyUntil, Status, Notes, CreatedBy
)
VALUES (
@assetCode, @assetName, @category, @brand, @model, @serialNumber,
@quantity, @unit, @department, @location, @custodian,
@purchaseDate, @purchasePrice, @warrantyUntil, @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();