// 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: `

Confirm your email

Hello ${username || 'there'},

Thank you for registering. Please confirm your email by clicking the button below:

Confirm Email

Or copy this URL into your browser:

${verifyUrl}

This link will expire in ${EMAIL_VERIFY_TOKEN_TTL_MINUTES} minutes.

If you did not register this account, you can ignore this message.

` }); 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 normalizeDepartmentName(value) { return String(value || '').trim(); } async function syncAssetDepartmentsFromInventory() { if (!pool) { return; } await pool.request().query(` WITH SourceDepartments AS ( SELECT DISTINCT LTRIM(RTRIM(Department)) AS DepartmentName FROM AssetInventory WHERE Department IS NOT NULL AND LTRIM(RTRIM(Department)) <> '' ) INSERT INTO AssetDepartments (DepartmentName) SELECT source.DepartmentName FROM SourceDepartments source WHERE NOT EXISTS ( SELECT 1 FROM AssetDepartments target WHERE LOWER(LTRIM(RTRIM(target.DepartmentName))) = LOWER(source.DepartmentName) ); `); } async function ensureDepartmentExists(departmentName) { const normalized = normalizeDepartmentName(departmentName); if (!normalized || !pool) { return; } await pool.request() .input('departmentName', sql.NVarChar, normalized) .query(` IF NOT EXISTS ( SELECT 1 FROM AssetDepartments WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) ) BEGIN INSERT INTO AssetDepartments (DepartmentName) VALUES (@departmentName); END `); } function parsePositiveInteger(value, fallback = 1) { const parsed = Number(value); if (Number.isInteger(parsed) && parsed > 0) { return parsed; } return fallback; } function parseNonNegativeInteger(value, fallback = 0) { const parsed = 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', 'active'].includes(normalized)) { return 'in_use'; } if (['maintenance', 'bao tri'].includes(normalized)) { return 'maintenance'; } if (['disposed', 'thanh ly', 'retired'].includes(normalized)) { return 'disposed'; } if (['in_stock', 'in stock', 'ton kho', 'warehouse'].includes(normalized)) { return 'in_stock'; } return 'in_use'; } function normalizeAssetPayload(payload = {}) { const quantity = 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 parseBorrowerEntries(rawBorrower) { const source = String(rawBorrower || '').trim(); if (!source) { return []; } const chunks = source .split(/[\n;]+/g) .map(item => String(item || '').trim()) .filter(Boolean); const merged = []; chunks.forEach(chunk => { let name = chunk; let quantity = 1; const labeledMatch = chunk.match(/^(.*?)(?:\s*-\s*[^:]+:\s*(\d+))\s*$/i); if (labeledMatch) { name = String(labeledMatch[1] || '').trim(); quantity = parseNonNegativeInteger(labeledMatch[2], 1); } else { const colonMatch = chunk.match(/^(.*?)\s*:\s*(\d+)\s*$/); const xMatch = chunk.match(/^(.*?)\s*x\s*(\d+)\s*$/i); const parenMatch = chunk.match(/^(.*?)\s*\(\s*(\d+)\s*\)\s*$/); const fallbackMatch = colonMatch || xMatch || parenMatch; if (fallbackMatch) { name = String(fallbackMatch[1] || '').trim(); quantity = parseNonNegativeInteger(fallbackMatch[2], 1); } } if (!name || quantity <= 0) { return; } const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (existed) { existed.quantity += quantity; } else { merged.push({ name, quantity }); } }); return merged; } function formatBorrowerEntries(entries = []) { if (!Array.isArray(entries) || !entries.length) { return null; } const normalized = entries .map(entry => ({ name: String(entry?.name || '').trim(), quantity: parseNonNegativeInteger(entry?.quantity, 0) })) .filter(entry => entry.name && entry.quantity > 0); if (!normalized.length) { return null; } return normalized.map(entry => `${entry.name} - so luong: ${entry.quantity}`).join('; '); } function mergeBorrowerEntries(existingBorrower, borrowerName, borrowQuantity) { const merged = parseBorrowerEntries(existingBorrower); const name = String(borrowerName || '').trim(); const quantity = parseNonNegativeInteger(borrowQuantity, 0); if (!name || quantity <= 0) { return formatBorrowerEntries(merged); } const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (existed) { existed.quantity += quantity; } else { merged.push({ name, quantity }); } return formatBorrowerEntries(merged); } function decreaseBorrowerEntries(existingBorrower, borrowerName, returnQuantity) { const merged = parseBorrowerEntries(existingBorrower); const name = String(borrowerName || '').trim(); const quantity = parseNonNegativeInteger(returnQuantity, 0); if (!name || quantity <= 0) { return { success: false, message: 'Invalid return payload', entries: merged }; } const existed = merged.find(entry => entry.name.toLowerCase() === name.toLowerCase()); if (!existed) { return { success: false, message: 'User has no borrowed quantity to return', entries: merged }; } if (existed.quantity < quantity) { return { success: false, message: `Return quantity (${quantity}) exceeds borrowed quantity (${existed.quantity})`, entries: merged }; } existed.quantity -= quantity; const normalized = merged.filter(entry => parseNonNegativeInteger(entry?.quantity, 0) > 0); return { success: true, entries: normalized, summary: formatBorrowerEntries(normalized) }; } function normalizeAssetRequestType(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'return' || normalized === 'tra' || normalized === 'return_asset') { return 'return'; } return 'borrow'; } function normalizeAssetRequestStatus(value) { const normalized = String(value || '').trim().toLowerCase(); if (normalized === 'approved' || normalized === 'approve' || normalized === 'accept' || normalized === 'accepted') { return 'approved'; } if (normalized === 'rejected' || normalized === 'reject' || normalized === 'declined') { return 'rejected'; } return 'pending'; } function normalizeImportToken(value) { return String(value || '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[\u0111\u0110]/g, 'd') .toLowerCase() .replace(/[^a-z0-9]/g, ''); } function isHeaderLikeAssetImportRow(row = {}) { const headerTokens = new Set([ 'stt', 'ngayve', 'mavattu', 'mavt', 'mataisan', 'mats', 'matscd', 'tentaisan', 'tenlinhkiensp', 'model', 'dvt', 'donvi', 'tondauky', 'tondauki', 'nhaptrongky', 'nhaptrongki', 'xuattrongky', 'xuattrongki', 'toncuoiky', 'toncuoiki', 'lidoxuat', 'lydoxuat', 'tinhtrang', 'vitri', 'duan', 'assetcode', 'assetname', 'quantity', 'importinperiod', 'exportinperiod', 'endingbalance', 'unit', 'location', 'department', 'project', 'status', 'notes' ]); const fields = [ row.assetCode, row.assetName, row.model, row.unit, row.status, row.location, row.department, row.project, row.importInPeriod, row.exportInPeriod, row.endingBalance, row.notes ]; const headerLikeCount = fields.reduce((count, value) => { const token = normalizeImportToken(value); return count + (token && headerTokens.has(token) ? 1 : 0); }, 0); return headerLikeCount >= 2; } 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(/[\u0111\u0110]/g, 'd') .toUpperCase() .replace(/[^A-Z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40); } function generateImportAssetCodeFromRow(mapped, rowNumber = 0) { const fromModel = sanitizeAssetCodeToken(mapped.model); const fromSerial = sanitizeAssetCodeToken(mapped.serialNumber); const fromName = sanitizeAssetCodeToken(mapped.assetName); const base = fromModel || fromSerial || fromName || 'ASSET'; const suffix = String(rowNumber || 0).padStart(4, '0'); return `IMP-${base}-${suffix}`; } function generateManualAssetCodeFromPayload(payload = {}) { const fromModel = sanitizeAssetCodeToken(payload.model); const fromSerial = sanitizeAssetCodeToken(payload.serialNumber); const fromName = sanitizeAssetCodeToken(payload.assetName); const base = (fromModel || fromSerial || fromName || 'ASSET').slice(0, 32); const now = new Date(); const timestamp = [ String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0'), String(now.getHours()).padStart(2, '0'), String(now.getMinutes()).padStart(2, '0'), String(now.getSeconds()).padStart(2, '0'), String(now.getMilliseconds()).padStart(3, '0') ].join(''); const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0'); return `AST-${base}-${timestamp}${randomSuffix}`; } async function generateUniqueManualAssetCode(payload = {}, maxAttempts = 8) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const candidate = generateManualAssetCodeFromPayload(payload); const existed = await pool.request() .input('assetCode', sql.NVarChar, candidate) .query(` SELECT TOP 1 AssetId FROM AssetInventory WHERE AssetCode = @assetCode `); if (existed.recordset.length === 0) { return candidate; } } throw new Error('Cannot generate unique asset code'); } function finalizeImportedAssetPayload(mapped, rowNumber = 0) { const result = { ...mapped }; if (!result.assetName) { result.assetName = String(result.model || result.serialNumber || result.assetCode || '').trim(); } if (!result.assetCode && result.assetName) { result.assetCode = generateImportAssetCodeFromRow(result, rowNumber); } return result; } function buildAssetImportIndexMap(headerRow) { const indexMap = { stt: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.stt), assetCode: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetCode), assetName: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.assetName), model: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.model), serialNumber: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.serialNumber), quantity: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.quantity), importInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.importInPeriod), exportInPeriod: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.exportInPeriod), endingBalance: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.endingBalance), unit: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.unit), department: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.department), project: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.project), location: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.location), custodian: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.custodian), purchaseDate: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchaseDate), purchasePrice: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.purchasePrice), status: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.status), notes: inferAssetImportColumnIndex(headerRow, ASSET_IMPORT_ALIASES.notes) }; if (indexMap.stt >= 0) { if (indexMap.purchaseDate < 0) indexMap.purchaseDate = indexMap.stt + 1; if (indexMap.assetCode < 0) indexMap.assetCode = indexMap.stt + 2; if (indexMap.assetName < 0) indexMap.assetName = indexMap.stt + 3; if (indexMap.model < 0) indexMap.model = indexMap.stt + 4; if (indexMap.unit < 0) indexMap.unit = indexMap.stt + 5; if (indexMap.quantity < 0) indexMap.quantity = indexMap.stt + 6; if (indexMap.importInPeriod < 0) indexMap.importInPeriod = indexMap.stt + 7; if (indexMap.exportInPeriod < 0) indexMap.exportInPeriod = indexMap.stt + 8; if (indexMap.endingBalance < 0) indexMap.endingBalance = indexMap.stt + 9; if (indexMap.notes < 0) indexMap.notes = indexMap.stt + 10; if (indexMap.status < 0) indexMap.status = indexMap.stt + 11; if (indexMap.location < 0) indexMap.location = indexMap.stt + 12; if (indexMap.project < 0) indexMap.project = indexMap.stt + 13; } return indexMap; } function mapAssetImportMatrixRowsByIndex(matrixRows, headerRowIndex) { const headerRow = Array.isArray(matrixRows[headerRowIndex]) ? matrixRows[headerRowIndex] : []; if (!headerRow.length) { return []; } const indexMap = buildAssetImportIndexMap(headerRow); if (indexMap.assetCode < 0 && indexMap.assetName < 0 && indexMap.model < 0 && indexMap.stt < 0) { return []; } const pick = (row, index) => { if (index < 0 || !Array.isArray(row)) { return ''; } return row[index] ?? ''; }; return matrixRows .slice(headerRowIndex + 1) .filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== '')) .map((row, rowOffset) => { const sttValue = parseAssetImportSttNumber(pick(row, indexMap.stt)); if (indexMap.stt >= 0 && sttValue === null) { return null; } const endingBalance = parseAssetImportNumericValue( pick(row, indexMap.endingBalance), 0 ); const mapped = { 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('[OK] Connected to SQL Server'); // Check and create database if not exists const masterConnection = new sql.ConnectionPool({ server: DB_SERVER, authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } }, options: { connectTimeout: DB_CONNECT_TIMEOUT, database: 'master', trustServerCertificate: DB_TRUST_CERTIFICATE, encrypt: DB_ENCRYPT } }); await masterConnection.connect(); const createDbResult = await masterConnection.request() .query(`IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'AccManager') BEGIN CREATE DATABASE AccManager; END`); await masterConnection.close(); // Now create tables in AccManager await createTables(); await migrateLegacyPasswords(); console.log('[OK] Database and tables created'); } catch (err) { console.error('Database connection failed:', err); process.exit(1); } } async function migrateLegacyPasswords() { try { const usersResult = await pool.request() .query('SELECT UserId, Password, ViewPassword FROM Users WHERE Password IS NOT NULL'); let migratedCount = 0; for (const row of usersResult.recordset) { const request = pool.request() .input('userId', sql.Int, row.UserId); let hasUpdates = false; const rawPassword = String(row.Password || ''); if (!row.ViewPassword && !isBcryptHash(rawPassword)) { request.input('viewPassword', sql.NVarChar, encryptPasswordForView(rawPassword)); hasUpdates = true; } if (!isBcryptHash(row.Password)) { const hashedPassword = await hashPassword(row.Password); request.input('password', sql.NVarChar, hashedPassword); hasUpdates = true; migratedCount += 1; } if (hasUpdates) { await request.query(`UPDATE Users SET ${!isBcryptHash(rawPassword) ? 'Password = @password' : 'Password = Password'} ${(!row.ViewPassword && !isBcryptHash(rawPassword)) ? ', ViewPassword = @viewPassword' : ''} WHERE UserId = @userId`); } } if (migratedCount > 0) { console.log(`[OK] Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`); } } catch (err) { console.error('Password migration error:', err.message); } } async function createTables() { const queries = [ // Users Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Users') BEGIN CREATE TABLE Users ( UserId INT PRIMARY KEY IDENTITY(1,1), Username NVARCHAR(50) UNIQUE NOT NULL, Password NVARCHAR(255) NOT NULL, Email NVARCHAR(100), FullName NVARCHAR(100), Role NVARCHAR(50) NOT NULL, Status NVARCHAR(20) DEFAULT 'Active', CreatedDate DATETIME DEFAULT GETDATE(), LastLogin DATETIME, IsActive BIT DEFAULT 1 ) END`, // Applications Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Applications') BEGIN CREATE TABLE Applications ( AppId INT PRIMARY KEY IDENTITY(1,1), Name NVARCHAR(100) NOT NULL, Type NVARCHAR(50), Status NVARCHAR(20) DEFAULT 'online', Icon NVARCHAR(50), Description NVARCHAR(500), Url NVARCHAR(255), CreatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT GETDATE() ) END`, // Accounts Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Accounts') BEGIN CREATE TABLE Accounts ( AccountId INT PRIMARY KEY IDENTITY(1,1), UserId INT NOT NULL, AppId INT NOT NULL, AccountUsername NVARCHAR(100), AccountPassword NVARCHAR(255), Email NVARCHAR(100), AccessLevel NVARCHAR(50), Status NVARCHAR(20) DEFAULT 'Active', Notes NVARCHAR(MAX), CreatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT GETDATE(), FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE, FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE ) END`, // Asset Inventory Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory') BEGIN CREATE TABLE AssetInventory ( AssetId INT PRIMARY KEY IDENTITY(1,1), AssetCode NVARCHAR(100) NOT NULL UNIQUE, AssetName NVARCHAR(255) NOT NULL, Model NVARCHAR(255), SerialNumber NVARCHAR(100), Quantity INT NOT NULL DEFAULT 0, ImportInPeriod INT NOT NULL DEFAULT 0, ExportInPeriod INT NOT NULL DEFAULT 0, EndingBalance INT NOT NULL DEFAULT 0, Unit NVARCHAR(50), Department NVARCHAR(100), Project NVARCHAR(150), Location NVARCHAR(150), Custodian NVARCHAR(100), Borrower NVARCHAR(255), ExportedBy NVARCHAR(100), PurchaseDate DATE NULL, PurchasePrice DECIMAL(18,2) NULL, Status NVARCHAR(30) NOT NULL DEFAULT 'in_use', Notes NVARCHAR(MAX), CreatedBy INT NULL, CreatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT GETDATE(), FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL ) END`, // Asset Departments Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments') BEGIN CREATE TABLE AssetDepartments ( DepartmentId INT PRIMARY KEY IDENTITY(1,1), DepartmentName NVARCHAR(100) NOT NULL, CreatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT GETDATE() ) END`, // Asset Borrow Requests Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests') BEGIN CREATE TABLE AssetBorrowRequests ( BorrowId INT PRIMARY KEY IDENTITY(1,1), AssetId INT NOT NULL, RequestType NVARCHAR(20) NOT NULL DEFAULT 'borrow', RequestStatus NVARCHAR(20) NOT NULL DEFAULT 'pending', BorrowerName NVARCHAR(100) NOT NULL, BorrowQuantity INT NOT NULL DEFAULT 1, Unit NVARCHAR(50), BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), RequestNote NVARCHAR(500) NULL, RejectReason NVARCHAR(1000) NULL, CreatedBy INT NULL, ProcessedBy INT NULL, ProcessedByName NVARCHAR(100) NULL, ProcessedDate DATETIME NULL, CreatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT GETDATE(), FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL, FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL ) END`, // AuditLog Table `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') BEGIN CREATE TABLE AuditLog ( LogId INT PRIMARY KEY IDENTITY(1,1), UserId INT, Action NVARCHAR(50), TableName NVARCHAR(50), RecordId INT, OldValue NVARCHAR(MAX), NewValue NVARCHAR(MAX), Timestamp DATETIME DEFAULT GETDATE(), FOREIGN KEY (UserId) REFERENCES Users(UserId) ) END` ]; for (let query of queries) { try { await pool.request().query(query); } catch (err) { console.error('Table creation error:', err.message); } } // Ensure AssetInventory indexes exist for lookup/filter performance try { await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_AssetCode') CREATE INDEX IX_AssetInventory_AssetCode ON AssetInventory(AssetCode);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Status') CREATE INDEX IX_AssetInventory_Status ON AssetInventory(Status);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetInventory_Department') CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);`); } catch (err) { console.error('AssetInventory index creation error:', err.message); } // Ensure AssetDepartments indexes exist try { await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`); } catch (err) { console.error('AssetDepartments index creation error:', err.message); } // Ensure AssetBorrowRequests indexes exist try { await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_AssetId') CREATE INDEX IX_AssetBorrowRequests_AssetId ON AssetBorrowRequests(AssetId);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_BorrowDate') CREATE INDEX IX_AssetBorrowRequests_BorrowDate ON AssetBorrowRequests(BorrowDate DESC);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestStatus') CREATE INDEX IX_AssetBorrowRequests_RequestStatus ON AssetBorrowRequests(RequestStatus);`); await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetBorrowRequests_RequestType') CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);`); } catch (err) { console.error('AssetBorrowRequests index creation error:', err.message); } // Ensure new columns exist on Applications for migrations try { await pool.request().query(`IF EXISTS ( SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('dbo.AssetInventory') AND name = 'Model' AND max_length < 510 ) ALTER TABLE AssetInventory ALTER COLUMN Model NVARCHAR(255) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ImportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ImportInPeriod DEFAULT(0);`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportInPeriod') IS NULL ALTER TABLE AssetInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_AssetInventory_ExportInPeriod DEFAULT(0);`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','EndingBalance') IS NULL ALTER TABLE AssetInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_AssetInventory_EndingBalance DEFAULT(0);`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Project') IS NULL ALTER TABLE AssetInventory ADD Project NVARCHAR(150) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Borrower') IS NULL ALTER TABLE AssetInventory ADD Borrower NVARCHAR(255) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportedBy') IS NULL ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','Unit') IS NULL ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','BorrowDate') IS NULL ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE));`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestType') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow');`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestStatus') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestStatus NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestStatus DEFAULT('approved');`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestNote') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestNote NVARCHAR(500) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RejectReason') IS NULL ALTER TABLE AssetBorrowRequests ADD RejectReason NVARCHAR(1000) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedBy') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedBy INT NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedByName') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedByName NVARCHAR(100) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','ProcessedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD ProcessedDate DATETIME NULL;`); await pool.request().query(` IF NOT EXISTS ( SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetBorrowRequests_ProcessedBy' ) ALTER TABLE AssetBorrowRequests ADD CONSTRAINT FK_AssetBorrowRequests_ProcessedBy FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL; `); await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`); await pool.request().query(`UPDATE AssetBorrowRequests SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');`); await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`); await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`); await pool.request().query(`UPDATE AssetInventory SET Quantity = ISNULL(NULLIF(Quantity, 0), EndingBalance);`); await pool.request().query(` UPDATE ai SET ai.ExportedBy = COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), NULLIF(LTRIM(RTRIM(u.Username)), '')) FROM AssetInventory ai LEFT JOIN Users u ON ai.CreatedBy = u.UserId WHERE ai.ExportedBy IS NULL `); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Category') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Category;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Brand') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN Brand;`); await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','WarrantyUntil') IS NOT NULL ALTER TABLE AssetInventory DROP COLUMN WarrantyUntil;`); await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`); await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`); await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerified') IS NULL ALTER TABLE Users ADD EmailVerified BIT NOT NULL CONSTRAINT DF_Users_EmailVerified DEFAULT(0);`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifiedAt') IS NULL ALTER TABLE Users ADD EmailVerifiedAt DATETIME NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyToken') IS NULL ALTER TABLE Users ADD EmailVerifyToken NVARCHAR(255) NULL;`); await pool.request().query(`IF COL_LENGTH('dbo.Users','EmailVerifyTokenExpires') IS NULL ALTER TABLE Users ADD EmailVerifyTokenExpires DATETIME NULL;`); await pool.request().query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE LOWER(ISNULL(Role, '')) = 'admin';`); // Backfill Url to empty string to avoid undefined in responses await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`); } catch (err) { console.error('Column addition error (Applications):', err.message); } // Sync legacy departments from AssetInventory to AssetDepartments try { await syncAssetDepartmentsFromInventory(); } catch (err) { console.error('AssetDepartments sync error:', err.message); } // Insert initial admin user try { const adminPasswordHash = await hashPassword('admin'); const adminViewPassword = encryptPasswordForView('admin'); await pool.request() .input('username', sql.NVarChar, 'admin') .input('password', sql.NVarChar, adminPasswordHash) .input('viewPassword', sql.NVarChar, adminViewPassword) .input('email', sql.NVarChar, 'admin@accmanager.local') .input('fullname', sql.NVarChar, 'Administrator') .input('role', sql.NVarChar, 'admin') .query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username) INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive, EmailVerified, EmailVerifiedAt) VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1, 1, GETDATE()) ELSE UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = ISNULL(EmailVerifiedAt, GETDATE()) WHERE Username = @username`); console.log('[OK] Admin user created: admin / admin'); } catch (err) { console.error('Admin user error:', err.message); } // Insert sample applications try { await pool.request() .query(`IF (SELECT COUNT(*) FROM Applications) = 0 BEGIN INSERT INTO Applications (Name, Type, Status, Icon, Description, Url) VALUES ('AWS', 'Cloud', 'online', 'cloud', 'Amazon Web Services', 'https://aws.amazon.com'), ('GitHub', 'VCS', 'online', 'code', 'GitHub - Version Control', 'https://github.com'), ('Google Workspace', 'Collaboration', 'online', 'mail', 'Google Workspace', 'https://workspace.google.com'), ('Nginx Proxy', 'Infra', 'offline', 'dns', 'Nginx Web Server', 'https://nginx.org') END`); console.log('[OK] Sample applications created'); } catch (err) { console.error('Applications error:', err.message); } } // ========================================== // API ROUTES - Authentication // ========================================== // Login endpoint app.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ success: false, message: 'Username and password are required' }); } const result = await pool.request() .input('username', sql.NVarChar, username) .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password, EmailVerified FROM Users WHERE (Username = @username OR Email = @username) AND IsActive = 1`); if (result.recordset.length > 0) { const dbUser = result.recordset[0]; const isValidPassword = await verifyPassword(password, dbUser.Password); if (!isValidPassword) { return res.status(401).json({ success: false, message: 'Invalid username or password' }); } if (!dbUser.EmailVerified) { return res.status(403).json({ success: false, message: 'Please confirm your email before signing in', requiresEmailVerification: true, email: dbUser.Email, username: dbUser.Username }); } // Upgrade old plain-text passwords after successful legacy login. if (!isBcryptHash(dbUser.Password)) { const upgradedHash = await hashPassword(password); await pool.request() .input('userId', sql.Int, dbUser.UserId) .input('password', sql.NVarChar, upgradedHash) .input('viewPassword', sql.NVarChar, encryptPasswordForView(password)) .query('UPDATE Users SET Password = @password, ViewPassword = ISNULL(ViewPassword, @viewPassword) WHERE UserId = @userId'); } const { Password: _, ...safeUser } = dbUser; const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' }; // Update last login await pool.request() .input('userId', sql.Int, user.UserId) .query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId'); res.json({ success: true, message: 'Login successful', user: user }); } else { res.status(401).json({ success: false, message: 'Invalid username or password' }); } } catch (err) { console.error('Login error:', err); res.status(500).json({ success: false, message: err.message }); } }); // Public registration endpoint app.post('/api/auth/register', async (req, res) => { try { const { username, password, email, fullname } = req.body; if (!username || !password || !email) { return res.status(400).json({ success: false, message: 'Username, password and email are required' }); } const normalizedEmail = String(email).trim().toLowerCase(); const safeUsername = String(username).trim(); const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); if (!isEmailFormatValid) { return res.status(400).json({ success: false, message: 'Email format is invalid' }); } // Prevent duplicate usernames/emails const existing = await pool.request() .input('username', sql.NVarChar, safeUsername) .input('email', sql.NVarChar, normalizedEmail) .query('SELECT TOP 1 UserId, Username, Email FROM Users WHERE Username = @username OR Email = @email'); if (existing.recordset.length > 0) { const existed = existing.recordset[0]; const duplicateByEmail = String(existed.Email || '').toLowerCase() === normalizedEmail; return res.status(409).json({ success: false, message: duplicateByEmail ? 'Email already exists' : 'Username already exists' }); } const hashedPassword = await hashPassword(password); const viewPassword = encryptPasswordForView(password); const { token, tokenHash } = generateEmailVerificationToken(); const safeFullname = fullname && fullname.trim() ? fullname.trim() : safeUsername; let guestRoleName = 'guest'; let guestRoleId = null; const hasRoleIdColResult = await pool.request() .query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId"); const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1; const hasRolesTableResult = await pool.request() .query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable"); const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1; if (hasRolesTable) { const guestRoleResult = await pool.request().query(` IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest') BEGIN INSERT INTO Roles (RoleName, Description) VALUES ('Guest', 'Default role for self-registered users'); END SELECT TOP 1 RoleId, RoleName FROM Roles WHERE LOWER(RoleName) = 'guest'; `); if (guestRoleResult.recordset.length > 0) { guestRoleId = guestRoleResult.recordset[0].RoleId; guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest'; } } let result; if (hasRoleIdColumn && guestRoleId !== null) { result = await pool.request() .input('username', sql.NVarChar, safeUsername) .input('password', sql.NVarChar, hashedPassword) .input('email', sql.NVarChar, normalizedEmail) .input('fullname', sql.NVarChar, safeFullname) .input('roleId', sql.Int, guestRoleId) .input('role', sql.NVarChar, guestRoleName) .input('viewPassword', sql.NVarChar, viewPassword) .input('emailVerifyToken', sql.NVarChar, tokenHash) .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES) .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires) OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`); } else { result = await pool.request() .input('username', sql.NVarChar, safeUsername) .input('password', sql.NVarChar, hashedPassword) .input('email', sql.NVarChar, normalizedEmail) .input('fullname', sql.NVarChar, safeFullname) .input('role', sql.NVarChar, guestRoleName) .input('viewPassword', sql.NVarChar, viewPassword) .input('emailVerifyToken', sql.NVarChar, tokenHash) .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES) .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive, EmailVerified, EmailVerifyToken, EmailVerifyTokenExpires) OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1, 0, @emailVerifyToken, DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()))`); } const inserted = result.recordset[0]; // Repair previous self-registered users that were wrongly assigned Admin RoleId. if (hasRoleIdColumn && guestRoleId !== null) { await pool.request() .input('guestRoleId', sql.Int, guestRoleId) .query(`UPDATE Users SET RoleId = @guestRoleId, Role = 'Guest' WHERE LOWER(Role) = 'guest' AND (RoleId IS NULL OR RoleId <> @guestRoleId)`); } const emailResult = await sendVerificationEmail({ email: normalizedEmail, username: safeUsername, token }); const responsePayload = { success: true, message: emailResult.sent ? 'Registration successful. Please check your email to confirm your account.' : 'Registration successful, but email sending is not configured. Please contact administrator or use verification preview link in development.', registrationPendingVerification: true, emailSent: emailResult.sent, userId: inserted?.UserId }; if (emailResult.previewUrl) { responsePayload.verificationPreviewUrl = emailResult.previewUrl; } if (emailResult.reason && !emailResult.sent) { responsePayload.emailError = emailResult.reason; } res.json(responsePayload); } catch (err) { console.error('Registration error:', err); res.status(500).json({ success: false, message: 'Registration failed' }); } }); app.get('/api/auth/verify-email', async (req, res) => { try { const token = String(req.query.token || '').trim(); if (!token) { return res.status(400).json({ success: false, message: 'Verification token is required' }); } const tokenHash = hashVerificationToken(token); const result = await pool.request() .input('token', sql.NVarChar, tokenHash) .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, EmailVerifyTokenExpires FROM Users WHERE EmailVerifyToken = @token`); if (result.recordset.length === 0) { return res.status(400).json({ success: false, message: 'Verification token is invalid or already used' }); } const verifiedUser = result.recordset[0]; const expiresAt = verifiedUser.EmailVerifyTokenExpires ? new Date(verifiedUser.EmailVerifyTokenExpires) : null; if (!expiresAt || expiresAt.getTime() < Date.now()) { return res.status(400).json({ success: false, message: 'Verification token has expired. Please request a new email.' }); } await pool.request() .input('userId', sql.Int, verifiedUser.UserId) .query(`UPDATE Users SET EmailVerified = 1, EmailVerifiedAt = GETDATE(), EmailVerifyToken = NULL, EmailVerifyTokenExpires = NULL WHERE UserId = @userId`); if (!verifiedUser.IsActive) { return res.status(403).json({ success: false, message: 'Email confirmed, but account is inactive. Please contact administrator.' }); } // Align verification flow with login payload shape to support auto-login on verify page. const { EmailVerifyTokenExpires: _expires, ...safeUser } = verifiedUser; const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' }; await pool.request() .input('userId', sql.Int, verifiedUser.UserId) .query('UPDATE Users SET LastLogin = GETDATE() WHERE UserId = @userId'); res.json({ success: true, message: 'Email confirmed successfully. Logging you in...', autoLogin: true, user }); } catch (err) { console.error('Verify email error:', err.message); res.status(500).json({ success: false, message: 'Email verification failed' }); } }); app.post('/api/auth/resend-verification', async (req, res) => { try { const identifier = String(req.body?.identifier || req.body?.email || '').trim(); if (!identifier) { return res.status(400).json({ success: false, message: 'Username or email is required' }); } const result = await pool.request() .input('identifier', sql.NVarChar, identifier) .query(`SELECT TOP 1 UserId, Username, Email, EmailVerified FROM Users WHERE Username = @identifier OR Email = @identifier`); if (result.recordset.length === 0) { return res.json({ success: true, message: 'If the account exists, a confirmation email has been sent.' }); } const user = result.recordset[0]; if (user.EmailVerified) { return res.json({ success: true, message: 'This email is already confirmed.' }); } if (!user.Email) { return res.status(400).json({ success: false, message: 'This account has no email. Please contact administrator.' }); } const { token, tokenHash } = generateEmailVerificationToken(); await pool.request() .input('userId', sql.Int, user.UserId) .input('tokenHash', sql.NVarChar, tokenHash) .input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES) .query(`UPDATE Users SET EmailVerifyToken = @tokenHash, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE()) WHERE UserId = @userId`); const emailResult = await sendVerificationEmail({ email: user.Email, username: user.Username, token }); const payload = { success: true, message: emailResult.sent ? 'Verification email sent. Please check your inbox.' : 'SMTP is not configured. Use development preview link or configure SMTP.', emailSent: emailResult.sent }; if (emailResult.previewUrl) { payload.verificationPreviewUrl = emailResult.previewUrl; } if (emailResult.reason && !emailResult.sent) { payload.emailError = emailResult.reason; } res.json(payload); } catch (err) { console.error('Resend verification error:', err.message); res.status(500).json({ success: false, message: 'Cannot resend verification email right now' }); } }); // Middleware for role-based access control function normalizeRole(value) { return String(value || '').trim().toLowerCase(); } function requireRoles(roles = [], message = 'Access denied') { const allowedRoles = new Set(roles.map(normalizeRole)); return (req, res, next) => { const userRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole); if (!allowedRoles.has(userRole)) { return res.status(403).json({ success: false, message }); } next(); }; } const requireAdmin = requireRoles(['admin'], 'Admin access required'); const requireAssetOrAdmin = requireRoles(['asset', 'admin'], 'Asset or Admin access required'); // ========================================== // API ROUTES - Roles // ========================================== // Get all roles app.get('/api/roles', async (req, res) => { try { const result = await pool.request() .query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName'); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Get role by ID app.get('/api/roles/:id', async (req, res) => { try { const result = await pool.request() .input('roleId', sql.Int, req.params.id) .query('SELECT * FROM Roles WHERE RoleId = @roleId'); if (result.recordset.length > 0) { res.json({ success: true, data: result.recordset[0] }); } else { res.status(404).json({ success: false, message: 'Role not found' }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Create new role (Admin only) app.post('/api/roles', requireAdmin, async (req, res) => { try { const { roleName, description } = req.body; const result = await pool.request() .input('roleName', sql.NVarChar, roleName) .input('description', sql.NVarChar, description) .query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName) BEGIN INSERT INTO Roles (RoleName, Description) VALUES (@roleName, @description); SELECT SCOPE_IDENTITY() as RoleId END ELSE BEGIN SELECT RoleId FROM Roles WHERE RoleName = @roleName END`); res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Update role (Admin only) app.put('/api/roles/:id', requireAdmin, async (req, res) => { try { const { roleName, description } = req.body; await pool.request() .input('roleId', sql.Int, req.params.id) .input('roleName', sql.NVarChar, roleName) .input('description', sql.NVarChar, description) .query(`UPDATE Roles SET RoleName = @roleName, Description = @description WHERE RoleId = @roleId`); res.json({ success: true, message: 'Role updated' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Delete role (Admin only) app.delete('/api/roles/:id', requireAdmin, async (req, res) => { try { // Check if role is in use const check = await pool.request() .input('roleId', sql.Int, req.params.id) .query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId'); if (check.recordset[0].Count > 0) { return res.status(400).json({ success: false, message: 'Cannot delete role - it is assigned to users' }); } await pool.request() .input('roleId', sql.Int, req.params.id) .query('DELETE FROM Roles WHERE RoleId = @roleId'); res.json({ success: true, message: 'Role deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Users // ========================================== // Get all users app.get('/api/users', async (req, res) => { try { const result = await pool.request() .query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName, u.Status, u.CreatedDate, u.LastLogin, u.IsActive, u.EmailVerified, u.EmailVerifiedAt FROM Users u LEFT JOIN Roles r ON u.RoleId = r.RoleId ORDER BY u.CreatedDate DESC`); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.get('/api/users/me', async (req, res) => { try { const userId = getUserIdFromRequest(req); if (!userId) { return res.status(401).json({ success: false, message: 'Missing or invalid user id' }); } const result = await pool.request() .input('userId', sql.Int, userId) .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt FROM Users WHERE UserId = @userId`); if (result.recordset.length === 0) { return res.status(404).json({ success: false, message: 'User not found' }); } res.json({ success: true, data: result.recordset[0] }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.put('/api/users/me', async (req, res) => { try { const userId = getUserIdFromRequest(req); if (!userId) { return res.status(401).json({ success: false, message: 'Missing or invalid user id' }); } const fullname = String(req.body?.fullname || '').trim(); const incomingEmail = String(req.body?.email || '').trim().toLowerCase(); const currentPassword = String(req.body?.currentPassword || ''); const newPassword = String(req.body?.newPassword || '').trim(); if (!fullname) { return res.status(400).json({ success: false, message: 'Full name is required' }); } if (!incomingEmail) { return res.status(400).json({ success: false, message: 'Email is required' }); } const isEmailFormatValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(incomingEmail); if (!isEmailFormatValid) { return res.status(400).json({ success: false, message: 'Email format is invalid' }); } const userResult = await pool.request() .input('userId', sql.Int, userId) .query(`SELECT UserId, Username, Email, Password, Role, RoleId FROM Users WHERE UserId = @userId`); if (userResult.recordset.length === 0) { return res.status(404).json({ success: false, message: 'User not found' }); } const existingUser = userResult.recordset[0]; const emailChanged = String(existingUser.Email || '').toLowerCase() !== incomingEmail; const shouldChangePassword = newPassword.length > 0; if (shouldChangePassword) { if (!currentPassword) { return res.status(400).json({ success: false, message: 'Current password is required to set a new password' }); } const isCurrentPasswordValid = await verifyPassword(currentPassword, existingUser.Password); if (!isCurrentPasswordValid) { return res.status(400).json({ success: false, message: 'Current password is incorrect' }); } } if (emailChanged) { const duplicateEmailResult = await pool.request() .input('email', sql.NVarChar, incomingEmail) .input('userId', sql.Int, userId) .query('SELECT TOP 1 UserId FROM Users WHERE Email = @email AND UserId <> @userId'); if (duplicateEmailResult.recordset.length > 0) { return res.status(409).json({ success: false, message: 'Email already exists' }); } } const request = pool.request() .input('userId', sql.Int, userId) .input('fullname', sql.NVarChar, fullname) .input('email', sql.NVarChar, incomingEmail); let token; if (emailChanged) { const tokenResult = generateEmailVerificationToken(); token = tokenResult.token; request.input('emailVerifyToken', sql.NVarChar, tokenResult.tokenHash); request.input('tokenTtlMinutes', sql.Int, EMAIL_VERIFY_TOKEN_TTL_MINUTES); } if (shouldChangePassword) { const hashedPassword = await hashPassword(newPassword); const viewPassword = encryptPasswordForView(newPassword); request.input('password', sql.NVarChar, hashedPassword); request.input('viewPassword', sql.NVarChar, viewPassword); } await request.query(`UPDATE Users SET FullName = @fullname, Email = @email ${shouldChangePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''} ${emailChanged ? ', EmailVerified = 0, EmailVerifiedAt = NULL, EmailVerifyToken = @emailVerifyToken, EmailVerifyTokenExpires = DATEADD(MINUTE, @tokenTtlMinutes, GETDATE())' : ''} WHERE UserId = @userId`); let emailResult = { sent: true }; if (emailChanged) { emailResult = await sendVerificationEmail({ email: incomingEmail, username: existingUser.Username, token }); } const safeResult = await pool.request() .input('userId', sql.Int, userId) .query(`SELECT UserId, Username, Email, FullName, Role, RoleId, Status, IsActive, CreatedDate, LastLogin, EmailVerified, EmailVerifiedAt FROM Users WHERE UserId = @userId`); const payload = { success: true, message: emailChanged ? 'Profile updated. Please confirm your new email address.' : (shouldChangePassword ? 'Profile and password updated successfully' : 'Profile updated successfully'), user: safeResult.recordset[0], verificationRequired: emailChanged, emailSent: emailChanged ? emailResult.sent : undefined }; if (emailChanged && emailResult.previewUrl) { payload.verificationPreviewUrl = emailResult.previewUrl; } if (emailChanged && emailResult.reason && !emailResult.sent) { payload.emailError = emailResult.reason; } res.json(payload); } catch (err) { console.error('Update profile error:', err.message); res.status(500).json({ success: false, message: 'Cannot update profile right now' }); } }); // Get user by ID app.get('/api/users/:id', requireAdmin, async (req, res) => { try { const result = await pool.request() .input('userId', sql.Int, req.params.id) .query(`SELECT u.*, r.RoleName FROM Users u LEFT JOIN Roles r ON u.RoleId = r.RoleId WHERE u.UserId = @userId`); if (result.recordset.length > 0) { const record = result.recordset[0]; const viewPassword = decryptPasswordForView(record.ViewPassword || ''); const fallbackPlainPassword = !isBcryptHash(record.Password) ? String(record.Password || '') : ''; const plainPassword = viewPassword || fallbackPlainPassword; const userDetails = { ...record, Password: plainPassword, PasswordAvailable: Boolean(plainPassword) }; res.json({ success: true, data: userDetails }); } else { res.status(404).json({ success: false, message: 'User not found' }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Create new user (Admin only) app.post('/api/users', requireAdmin, async (req, res) => { try { const { username, password, email, fullname, roleId } = req.body; if (!username || !password) { return res.status(400).json({ success: false, message: 'Username and password are required' }); } const hashedPassword = await hashPassword(password); const viewPassword = encryptPasswordForView(password); const finalRoleId = roleId || 2; // Default to Guest role // Get role name from Roles table const roleResult = await pool.request() .input('roleId', sql.Int, finalRoleId) .query('SELECT RoleName FROM Roles WHERE RoleId = @roleId'); const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest'; const result = await pool.request() .input('username', sql.NVarChar, username) .input('password', sql.NVarChar, hashedPassword) .input('email', sql.NVarChar, email || null) .input('fullname', sql.NVarChar, fullname) .input('roleId', sql.Int, finalRoleId) .input('role', sql.NVarChar, roleName) .input('viewPassword', sql.NVarChar, viewPassword) .query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username) BEGIN INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, IsActive) VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 1); SELECT SCOPE_IDENTITY() as UserId END ELSE BEGIN SELECT NULL as UserId END`); if (result.recordset[0].UserId) { res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId }); } else { res.status(400).json({ success: false, message: 'Username already exists' }); } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Update user (Admin only) app.put('/api/users/:id', requireAdmin, async (req, res) => { try { const { email, fullname, roleId, status, isActive, password } = req.body; const nextPassword = typeof password === 'string' ? password.trim() : ''; const shouldUpdatePassword = nextPassword.length > 0; // Get role name from Roles table let roleName = ''; if (roleId) { const roleResult = await pool.request() .input('roleId', sql.Int, roleId) .query('SELECT RoleName FROM Roles WHERE RoleId = @roleId'); roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : ''; } const request = pool.request() .input('userId', sql.Int, req.params.id) .input('email', sql.NVarChar, email || null) .input('fullname', sql.NVarChar, fullname) .input('roleId', sql.Int, roleId) .input('role', sql.NVarChar, roleName) .input('status', sql.NVarChar, status) .input('isActive', sql.Bit, isActive); if (shouldUpdatePassword) { const hashedPassword = await hashPassword(nextPassword); const viewPassword = encryptPasswordForView(nextPassword); request.input('password', sql.NVarChar, hashedPassword); request.input('viewPassword', sql.NVarChar, viewPassword); } await request.query(`UPDATE Users SET Email = @email, FullName = @fullname, RoleId = @roleId, Role = @role, Status = @status, IsActive = @isActive ${shouldUpdatePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''} WHERE UserId = @userId`); res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Delete user (Admin only) app.delete('/api/users/:id', requireAdmin, async (req, res) => { try { // Prevent deleting the current admin user (ID = 1) if (req.params.id === '1') { return res.status(400).json({ success: false, message: 'Cannot delete the primary admin user' }); } // Delete associated accounts first await pool.request() .input('userId', sql.Int, req.params.id) .query('DELETE FROM Accounts WHERE UserId = @userId'); // Then delete the user await pool.request() .input('userId', sql.Int, req.params.id) .query('DELETE FROM Users WHERE UserId = @userId'); res.json({ success: true, message: 'User deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Applications // ========================================== // Get all applications app.get('/api/applications', async (req, res) => { try { const result = await pool.request() .query('SELECT AppId, Name, Type, Status, Icon, Description, Url, CreatedDate, UpdatedDate FROM Applications ORDER BY Name'); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Create application app.post('/api/applications', async (req, res) => { try { const { name, type, status, icon, description, url } = req.body; const result = await pool.request() .input('name', sql.NVarChar, name) .input('type', sql.NVarChar, type) .input('status', sql.NVarChar, status) .input('icon', sql.NVarChar, icon) .input('description', sql.NVarChar, description) .input('url', sql.NVarChar, url) .query(`INSERT INTO Applications (Name, Type, Status, Icon, Description, Url) VALUES (@name, @type, @status, @icon, @description, @url); SELECT SCOPE_IDENTITY() as AppId`); res.json({ success: true, message: 'Application created', appId: result.recordset[0].AppId }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Update application app.put('/api/applications/:id', async (req, res) => { try { const { name, type, status, icon, description, url } = req.body; await pool.request() .input('appId', sql.Int, req.params.id) .input('name', sql.NVarChar, name) .input('type', sql.NVarChar, type) .input('status', sql.NVarChar, status) .input('icon', sql.NVarChar, icon) .input('description', sql.NVarChar, description) .input('url', sql.NVarChar, url) .query(`UPDATE Applications SET Name = @name, Type = @type, Status = @status, Icon = @icon, Description = @description, Url = @url, UpdatedDate = GETDATE() WHERE AppId = @appId`); res.json({ success: true, message: 'Application updated' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Delete application app.delete('/api/applications/:id', async (req, res) => { try { await pool.request() .input('appId', sql.Int, req.params.id) .query('DELETE FROM Applications WHERE AppId = @appId'); res.json({ success: true, message: 'Application deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Accounts // ========================================== // Get accounts for a user app.get('/api/accounts/user/:userId', async (req, res) => { try { const result = await pool.request() .input('userId', sql.Int, req.params.userId) .query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username FROM Accounts a JOIN Applications app ON a.AppId = app.AppId JOIN Users u ON a.UserId = u.UserId WHERE a.UserId = @userId ORDER BY a.CreatedDate DESC`); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Get all accounts (from all users) app.get('/api/accounts/all', async (req, res) => { try { const result = await pool.request() .query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName FROM Accounts a JOIN Applications app ON a.AppId = app.AppId JOIN Users u ON a.UserId = u.UserId ORDER BY a.CreatedDate DESC`); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Create account app.post('/api/accounts', async (req, res) => { try { const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body; const result = await pool.request() .input('userId', sql.Int, userId) .input('appId', sql.Int, appId) .input('accountUsername', sql.NVarChar, accountUsername) .input('accountPassword', sql.NVarChar, accountPassword) .input('email', sql.NVarChar, email) .input('accessLevel', sql.NVarChar, accessLevel) .input('notes', sql.NVarChar, notes) .query(`INSERT INTO Accounts (UserId, AppId, AccountUsername, AccountPassword, Email, AccessLevel, Notes) VALUES (@userId, @appId, @accountUsername, @accountPassword, @email, @accessLevel, @notes); SELECT SCOPE_IDENTITY() as AccountId`); res.json({ success: true, message: 'Account created', accountId: result.recordset[0].AccountId }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Update account app.put('/api/accounts/:id', async (req, res) => { try { const { userId, appId, accountUsername, accountPassword, email, accessLevel, notes } = req.body; await pool.request() .input('accountId', sql.Int, req.params.id) .input('userId', sql.Int, userId) .input('appId', sql.Int, appId) .input('accountUsername', sql.NVarChar, accountUsername) .input('accountPassword', sql.NVarChar, accountPassword) .input('email', sql.NVarChar, email) .input('accessLevel', sql.NVarChar, accessLevel) .input('notes', sql.NVarChar, notes) .query(`UPDATE Accounts SET UserId = @userId, AppId = @appId, AccountUsername = @accountUsername, AccountPassword = @accountPassword, Email = @email, AccessLevel = @accessLevel, Notes = @notes, UpdatedDate = GETDATE() WHERE AccountId = @accountId`); res.json({ success: true, message: 'Account updated' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Delete account app.delete('/api/accounts/:id', async (req, res) => { try { await pool.request() .input('accountId', sql.Int, req.params.id) .query('DELETE FROM Accounts WHERE AccountId = @accountId'); res.json({ success: true, message: 'Account deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Asset Departments // ========================================== app.get('/api/asset-departments', async (req, res) => { try { await syncAssetDepartmentsFromInventory(); const result = await pool.request().query(` SELECT d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate, COUNT(ai.AssetId) AS AssetCount FROM AssetDepartments d LEFT JOIN AssetInventory ai ON LOWER(LTRIM(RTRIM(ai.Department))) = LOWER(LTRIM(RTRIM(d.DepartmentName))) GROUP BY d.DepartmentId, d.DepartmentName, d.CreatedDate, d.UpdatedDate ORDER BY d.DepartmentName ASC `); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.post('/api/asset-departments', requireAssetOrAdmin, async (req, res) => { try { const departmentName = normalizeDepartmentName(req.body?.departmentName); if (!departmentName) { return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' }); } await syncAssetDepartmentsFromInventory(); const existed = await pool.request() .input('departmentName', sql.NVarChar, departmentName) .query(` SELECT TOP 1 DepartmentId FROM AssetDepartments WHERE LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) `); if (existed.recordset.length > 0) { return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } const inserted = await pool.request() .input('departmentName', sql.NVarChar, departmentName) .query(` INSERT INTO AssetDepartments (DepartmentName) VALUES (@departmentName); SELECT SCOPE_IDENTITY() AS DepartmentId; `); res.json({ success: true, message: 'Đã thêm phòng ban', departmentId: inserted.recordset[0]?.DepartmentId }); } catch (err) { if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } res.status(500).json({ success: false, message: err.message }); } }); app.put('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { try { const departmentId = Number(req.params.id); if (!Number.isInteger(departmentId) || departmentId <= 0) { return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' }); } const departmentName = normalizeDepartmentName(req.body?.departmentName); if (!departmentName) { return res.status(400).json({ success: false, message: 'Tên phòng ban là bắt buộc' }); } await syncAssetDepartmentsFromInventory(); const currentResult = await pool.request() .input('departmentId', sql.Int, departmentId) .query(` SELECT DepartmentId, DepartmentName FROM AssetDepartments WHERE DepartmentId = @departmentId `); if (currentResult.recordset.length === 0) { return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' }); } const currentDepartment = currentResult.recordset[0]; const currentName = String(currentDepartment.DepartmentName || '').trim(); if (currentName.toLowerCase() === departmentName.toLowerCase()) { return res.json({ success: true, message: 'Đã cập nhật phòng ban' }); } const duplicated = await pool.request() .input('departmentName', sql.NVarChar, departmentName) .input('departmentId', sql.Int, departmentId) .query(` SELECT TOP 1 DepartmentId FROM AssetDepartments WHERE DepartmentId <> @departmentId AND LOWER(LTRIM(RTRIM(DepartmentName))) = LOWER(@departmentName) `); if (duplicated.recordset.length > 0) { return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } const transaction = new sql.Transaction(pool); await transaction.begin(); try { await new sql.Request(transaction) .input('departmentId', sql.Int, departmentId) .input('departmentName', sql.NVarChar, departmentName) .query(` UPDATE AssetDepartments SET DepartmentName = @departmentName, UpdatedDate = GETDATE() WHERE DepartmentId = @departmentId `); await new sql.Request(transaction) .input('oldDepartmentName', sql.NVarChar, currentName) .input('newDepartmentName', sql.NVarChar, departmentName) .query(` UPDATE AssetInventory SET Department = @newDepartmentName, UpdatedDate = GETDATE() WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@oldDepartmentName) `); await transaction.commit(); res.json({ success: true, message: 'Đã cập nhật phòng ban' }); } catch (transactionErr) { try { await transaction.rollback(); } catch (rollbackErr) { // Ignore rollback errors if transaction already ended. } throw transactionErr; } } catch (err) { if (String(err.message || '').includes('UX_AssetDepartments_DepartmentName')) { return res.status(409).json({ success: false, message: 'Phong ban da ton tai' }); } res.status(500).json({ success: false, message: err.message }); } }); app.delete('/api/asset-departments/:id', requireAssetOrAdmin, async (req, res) => { try { const departmentId = Number(req.params.id); if (!Number.isInteger(departmentId) || departmentId <= 0) { return res.status(400).json({ success: false, message: 'Mã phòng ban không hợp lệ?' }); } await syncAssetDepartmentsFromInventory(); const currentResult = await pool.request() .input('departmentId', sql.Int, departmentId) .query(` SELECT DepartmentId, DepartmentName FROM AssetDepartments WHERE DepartmentId = @departmentId `); if (currentResult.recordset.length === 0) { return res.status(404).json({ success: false, message: 'Không tìm thấy phòng ban' }); } const departmentName = String(currentResult.recordset[0].DepartmentName || '').trim(); const transaction = new sql.Transaction(pool); await transaction.begin(); try { await new sql.Request(transaction) .input('departmentName', sql.NVarChar, departmentName) .query(` UPDATE AssetInventory SET Department = NULL, UpdatedDate = GETDATE() WHERE LOWER(LTRIM(RTRIM(Department))) = LOWER(@departmentName) `); await new sql.Request(transaction) .input('departmentId', sql.Int, departmentId) .query(` DELETE FROM AssetDepartments WHERE DepartmentId = @departmentId `); await transaction.commit(); res.json({ success: true, message: 'Đã xóa phòng ban' }); } catch (transactionErr) { try { await transaction.rollback(); } catch (rollbackErr) { // Ignore rollback errors if transaction already ended. } throw transactionErr; } } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Asset Inventory // ========================================== app.get('/api/asset-borrows', async (req, res) => { try { const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole); const requesterId = getUserIdFromRequest(req); const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset'; const request = pool.request(); if (!canManageRequests) { request.input('requesterId', sql.Int, requesterId || -1); } const result = await request.query(` SELECT br.BorrowId, br.AssetId, ai.AssetCode, ai.AssetName, br.RequestType, br.RequestStatus, br.BorrowerName, br.BorrowQuantity, COALESCE(NULLIF(LTRIM(RTRIM(br.Unit)), ''), ai.Unit) AS Unit, br.BorrowDate, br.RequestNote, br.RejectReason, br.CreatedBy, br.ProcessedBy, br.ProcessedByName, br.ProcessedDate, br.CreatedDate FROM AssetBorrowRequests br LEFT JOIN AssetInventory ai ON ai.AssetId = br.AssetId ${canManageRequests ? '' : 'WHERE br.CreatedBy = @requesterId'} ORDER BY br.CreatedDate DESC, br.BorrowId DESC `); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.post('/api/asset-borrows', async (req, res) => { try { const createdBy = getUserIdFromRequest(req); const actorName = await getUserDisplayNameById(createdBy); const assetId = Number(req.body?.assetId); const requestType = normalizeAssetRequestType(req.body?.requestType); const borrowQuantity = parseNonNegativeInteger(req.body?.quantity, 0); const requestedBorrowDate = parseNullableDate(req.body?.borrowDate); const borrowDate = requestedBorrowDate || new Date(); const borrowerName = String(actorName || req.body?.borrowerName || '').trim(); const requestNote = String(req.body?.note || '').trim() || null; if (!Number.isInteger(assetId) || assetId <= 0) { return res.status(400).json({ success: false, message: 'Tai san khong hop le' }); } if (!borrowerName) { return res.status(400).json({ success: false, message: 'Khong xac dinh duoc nguoi tao don' }); } if (borrowQuantity <= 0) { return res.status(400).json({ success: false, message: 'So luong phai lon hon 0' }); } const assetResult = await pool.request() .input('assetId', sql.Int, assetId) .query(` SELECT TOP 1 AssetId, AssetCode, AssetName, Quantity, ImportInPeriod, Borrower, Unit FROM AssetInventory WHERE AssetId = @assetId `); const asset = assetResult.recordset?.[0]; if (!asset) { return res.status(404).json({ success: false, message: 'Khong tim thay tai san' }); } const currentBorrowedEntries = parseBorrowerEntries(asset.Borrower); const currentBorrowed = currentBorrowedEntries.reduce((sum, entry) => ( sum + parseNonNegativeInteger(entry?.quantity, 0) ), 0); const endingBalance = Math.max( parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed, 0 ); const unit = String(req.body?.unit || '').trim() || String(asset.Unit || '').trim() || null; if (requestType === 'borrow') { if (endingBalance <= 0) { return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky' }); } if (borrowQuantity > endingBalance) { return res.status(400).json({ success: false, message: `So luong muon (${borrowQuantity}) vuot qua ton cuoi ky (${endingBalance})` }); } } else { const existed = currentBorrowedEntries.find(entry => entry.name.toLowerCase() === borrowerName.toLowerCase()); const borrowedQuantity = parseNonNegativeInteger(existed?.quantity, 0); if (borrowedQuantity <= 0) { return res.status(400).json({ success: false, message: 'Ban chua co du lieu muon tai san nay de tao don tra' }); } if (borrowQuantity > borrowedQuantity) { return res.status(400).json({ success: false, message: `So luong tra (${borrowQuantity}) vuot qua so luong dang muon (${borrowedQuantity})` }); } } const insertResult = await pool.request() .input('assetId', sql.Int, assetId) .input('requestType', sql.NVarChar, requestType) .input('requestStatus', sql.NVarChar, 'pending') .input('borrowerName', sql.NVarChar, borrowerName) .input('borrowQuantity', sql.Int, borrowQuantity) .input('unit', sql.NVarChar, unit) .input('borrowDate', sql.Date, borrowDate) .input('requestNote', sql.NVarChar, requestNote) .input('createdBy', sql.Int, createdBy) .query(` INSERT INTO AssetBorrowRequests ( AssetId, RequestType, RequestStatus, BorrowerName, BorrowQuantity, Unit, BorrowDate, RequestNote, CreatedBy ) VALUES ( @assetId, @requestType, @requestStatus, @borrowerName, @borrowQuantity, @unit, @borrowDate, @requestNote, @createdBy ); SELECT SCOPE_IDENTITY() AS BorrowId; `); res.json({ success: true, message: requestType === 'return' ? 'Tao don tra tai san thanh cong. Don dang cho xu ly.' : 'Tao don muon tai san thanh cong. Don dang cho xu ly.', data: { borrowId: insertResult.recordset?.[0]?.BorrowId || null } }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res) => { const transaction = new sql.Transaction(pool); try { const borrowId = Number(req.params.id); const action = normalizeAssetRequestStatus(req.body?.action); const rejectReason = String(req.body?.rejectReason || '').trim() || null; const processedBy = getUserIdFromRequest(req); const processorName = String( await getUserDisplayNameById(processedBy) || req.headers['x-user-role'] || 'Asset/Admin' ).trim(); if (!Number.isInteger(borrowId) || borrowId <= 0) { return res.status(400).json({ success: false, message: 'Ma don khong hop le' }); } if (!['approved', 'rejected'].includes(action)) { return res.status(400).json({ success: false, message: 'Hanh dong khong hop le' }); } if (action === 'rejected' && !rejectReason) { return res.status(400).json({ success: false, message: 'Vui long nhap ly do tu choi' }); } await transaction.begin(); const requestResult = await new sql.Request(transaction) .input('borrowId', sql.Int, borrowId) .query(` SELECT TOP 1 br.BorrowId, br.AssetId, br.RequestType, br.RequestStatus, br.BorrowerName, br.BorrowQuantity, br.BorrowDate, br.Unit, ai.AssetCode, ai.AssetName, ai.Quantity, ai.ImportInPeriod, ai.Borrower, ai.Unit AS AssetUnit FROM AssetBorrowRequests br INNER JOIN AssetInventory ai ON ai.AssetId = br.AssetId WHERE br.BorrowId = @borrowId `); const targetRequest = requestResult.recordset?.[0]; if (!targetRequest) { await transaction.rollback(); return res.status(404).json({ success: false, message: 'Khong tim thay don can xu ly' }); } const currentStatus = normalizeAssetRequestStatus(targetRequest.RequestStatus); if (currentStatus !== 'pending') { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Don nay da duoc xu ly truoc do' }); } if (action === 'approved') { const requestType = normalizeAssetRequestType(targetRequest.RequestType); const borrowerName = String(targetRequest.BorrowerName || '').trim(); const requestQuantity = parseNonNegativeInteger(targetRequest.BorrowQuantity, 0); if (requestType === 'borrow') { const currentBorrowed = parseBorrowerEntries(targetRequest.Borrower).reduce((sum, entry) => ( sum + parseNonNegativeInteger(entry?.quantity, 0) ), 0); const endingBalance = Math.max( parseNonNegativeInteger(targetRequest.Quantity, 0) + parseNonNegativeInteger(targetRequest.ImportInPeriod, 0) - currentBorrowed, 0 ); if (requestQuantity > endingBalance) { await transaction.rollback(); return res.status(400).json({ success: false, message: `Khong du ton kho de duyet. Ton hien tai: ${endingBalance}` }); } const mergedBorrowerSummary = mergeBorrowerEntries( targetRequest.Borrower, borrowerName, requestQuantity ); if (mergedBorrowerSummary && mergedBorrowerSummary.length > 255) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Thong tin nguoi muon qua dai, vui long tu choi don va yeu cau nguoi dung dieu chinh.' }); } await new sql.Request(transaction) .input('assetId', sql.Int, targetRequest.AssetId) .input('borrower', sql.NVarChar, mergedBorrowerSummary) .input('exportedBy', sql.NVarChar, processorName || null) .query(` UPDATE AssetInventory SET Borrower = @borrower, ExportedBy = @exportedBy, UpdatedDate = GETDATE() WHERE AssetId = @assetId `); } else { const decreased = decreaseBorrowerEntries( targetRequest.Borrower, borrowerName, requestQuantity ); if (!decreased.success) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Không thể duyệt trả tài sản: số lượng trả không hợp lệ hoặc không còn người mượn. Bạn có thể xóa đơn chờ này.' }); } const borrowerSummary = decreased.summary || null; await new sql.Request(transaction) .input('assetId', sql.Int, targetRequest.AssetId) .input('borrower', sql.NVarChar, borrowerSummary) .input('exportedBy', sql.NVarChar, processorName || null) .query(` UPDATE AssetInventory SET Borrower = @borrower, ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END, UpdatedDate = GETDATE() WHERE AssetId = @assetId `); } } await new sql.Request(transaction) .input('borrowId', sql.Int, borrowId) .input('requestStatus', sql.NVarChar, action) .input('rejectReason', sql.NVarChar, action === 'rejected' ? rejectReason : null) .input('processedBy', sql.Int, processedBy) .input('processedByName', sql.NVarChar, processorName || null) .query(` UPDATE AssetBorrowRequests SET RequestStatus = @requestStatus, RejectReason = @rejectReason, ProcessedBy = @processedBy, ProcessedByName = @processedByName, ProcessedDate = GETDATE(), UpdatedDate = GETDATE() WHERE BorrowId = @borrowId `); await transaction.commit(); return res.json({ success: true, message: action === 'approved' ? 'Da duyet don thanh cong' : 'Da tu choi don' }); } catch (err) { try { await transaction.rollback(); } catch (rollbackErr) { // Ignore rollback errors when transaction already finished. } return res.status(500).json({ success: false, message: err.message }); } }); app.delete('/api/asset-borrows/:id', async (req, res) => { try { const requesterRole = normalizeRole(req.headers['x-user-role'] || req.query.userRole); const requesterId = getUserIdFromRequest(req); const canManageRequests = requesterRole === 'admin' || requesterRole === 'asset'; const borrowId = Number(req.params.id); if (!Number.isInteger(borrowId) || borrowId <= 0) { return res.status(400).json({ success: false, message: 'Ma don khong hop le' }); } if (!canManageRequests && (!Number.isInteger(requesterId) || requesterId <= 0)) { return res.status(401).json({ success: false, message: 'Yeu cau xac thuc nguoi dung' }); } const deleteResult = await pool.request() .input('borrowId', sql.Int, borrowId) .input('requesterId', sql.Int, requesterId || -1) .query(` DELETE FROM AssetBorrowRequests OUTPUT DELETED.BorrowId WHERE BorrowId = @borrowId AND LOWER(LTRIM(RTRIM(ISNULL(RequestStatus, '')))) = 'pending' ${canManageRequests ? '' : 'AND CreatedBy = @requesterId'} `); if (Array.isArray(deleteResult.recordset) && deleteResult.recordset.length > 0) { return res.json({ success: true, message: 'Da huy don cho' }); } const existed = await pool.request() .input('borrowId', sql.Int, borrowId) .query(` SELECT TOP 1 BorrowId, RequestStatus, CreatedBy FROM AssetBorrowRequests WHERE BorrowId = @borrowId `); const row = existed.recordset?.[0]; if (!row) { return res.status(404).json({ success: false, message: 'Khong tim thay don can xoa' }); } if (!canManageRequests && Number(row.CreatedBy) !== requesterId) { return res.status(403).json({ success: false, message: 'Ban chi duoc huy don do chinh minh tao' }); } const currentStatus = normalizeAssetRequestStatus(row.RequestStatus); if (currentStatus !== 'pending') { return res.status(400).json({ success: false, message: 'Chi duoc huy don o trang thai cho xu ly' }); } return res.status(400).json({ success: false, message: 'Khong the huy don vao luc nay' }); } catch (err) { return res.status(500).json({ success: false, message: err.message }); } }); app.get('/api/assets', async (req, res) => { try { const result = await pool.request().query(` SELECT AssetId, AssetCode, AssetName, Model, SerialNumber, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, PurchaseDate, PurchasePrice, Status, Notes, CreatedBy, CreatedDate, UpdatedDate FROM AssetInventory ORDER BY UpdatedDate DESC, AssetName ASC `); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.get('/api/assets/search', async (req, res) => { try { const rawKeyword = String(req.query.q || '').trim(); const keywordLike = `%${rawKeyword}%`; const limit = Math.min(parsePositiveInteger(req.query.limit, 80), 200); const offset = parseNonNegativeInteger(req.query.offset, 0); const result = await pool.request() .input('limit', sql.Int, limit) .input('offset', sql.Int, offset) .input('keyword', sql.NVarChar, rawKeyword) .input('keywordLike', sql.NVarChar, keywordLike) .query(` ;WITH FilteredAssets AS ( SELECT AssetId, AssetCode, AssetName, Unit, UpdatedDate, CASE WHEN @keyword <> '' AND AssetCode LIKE @keywordLike THEN 0 ELSE 1 END AS CodeRank, CASE WHEN @keyword <> '' AND AssetName LIKE @keywordLike THEN 0 ELSE 1 END AS NameRank FROM AssetInventory WHERE @keyword = '' OR AssetCode LIKE @keywordLike OR AssetName LIKE @keywordLike OR Model LIKE @keywordLike ), OrderedAssets AS ( SELECT AssetId, AssetCode, AssetName, Unit, ROW_NUMBER() OVER ( ORDER BY CodeRank ASC, NameRank ASC, UpdatedDate DESC, AssetName ASC ) AS RowNum FROM FilteredAssets ) SELECT AssetId, AssetCode, AssetName, Unit FROM OrderedAssets WHERE RowNum > @offset AND RowNum <= (@offset + @limit) ORDER BY RowNum; SELECT COUNT(*) AS TotalCount FROM AssetInventory WHERE @keyword = '' OR AssetCode LIKE @keywordLike OR AssetName LIKE @keywordLike OR Model LIKE @keywordLike; `); const rows = Array.isArray(result.recordsets?.[0]) ? result.recordsets[0] : []; const totalCount = Number(result.recordsets?.[1]?.[0]?.TotalCount) || 0; const hasMore = offset + rows.length < totalCount; res.json({ success: true, data: rows, hasMore, total: totalCount }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.get('/api/assets/:id', async (req, res) => { try { const result = await pool.request() .input('assetId', sql.Int, req.params.id) .query(` SELECT AssetId, AssetCode, AssetName, Model, SerialNumber, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, PurchaseDate, PurchasePrice, Status, Notes, CreatedBy, CreatedDate, UpdatedDate FROM AssetInventory WHERE AssetId = @assetId `); if (result.recordset.length === 0) { return res.status(404).json({ success: false, message: 'Asset not found' }); } res.json({ success: true, data: result.recordset[0] }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.post('/api/assets', requireAssetOrAdmin, async (req, res) => { try { const payload = normalizeAssetPayload(req.body); const createdBy = getUserIdFromRequest(req); const exportedBy = await getUserDisplayNameById(createdBy); if (!payload.assetName) { return res.status(400).json({ success: false, message: 'Asset name is required' }); } if (!payload.assetCode) { payload.assetCode = await generateUniqueManualAssetCode(payload); } await ensureDepartmentExists(payload.department); const result = await pool.request() .input('assetCode', sql.NVarChar, payload.assetCode) .input('assetName', sql.NVarChar, payload.assetName) .input('model', sql.NVarChar, payload.model) .input('serialNumber', sql.NVarChar, payload.serialNumber) .input('quantity', sql.Int, payload.quantity) .input('importInPeriod', sql.Int, payload.importInPeriod) .input('exportInPeriod', sql.Int, payload.exportInPeriod) .input('endingBalance', sql.Int, payload.endingBalance) .input('unit', sql.NVarChar, payload.unit) .input('department', sql.NVarChar, payload.department) .input('project', sql.NVarChar, payload.project) .input('location', sql.NVarChar, payload.location) .input('custodian', sql.NVarChar, payload.custodian) .input('borrower', sql.NVarChar, payload.borrower) .input('exportedBy', sql.NVarChar, exportedBy) .input('purchaseDate', sql.Date, payload.purchaseDate) .input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice) .input('status', sql.NVarChar, payload.status) .input('notes', sql.NVarChar, payload.notes) .input('createdBy', sql.Int, createdBy) .query(` INSERT INTO AssetInventory ( AssetCode, AssetName, Model, SerialNumber, Quantity, ImportInPeriod, ExportInPeriod, EndingBalance, Unit, Department, Project, Location, Custodian, Borrower, ExportedBy, PurchaseDate, PurchasePrice, Status, Notes, CreatedBy ) VALUES ( @assetCode, @assetName, @model, @serialNumber, @quantity, @importInPeriod, @exportInPeriod, @endingBalance, @unit, @department, @project, @location, @custodian, @borrower, @exportedBy, @purchaseDate, @purchasePrice, @status, @notes, @createdBy ); SELECT SCOPE_IDENTITY() AS AssetId; `); res.json({ success: true, message: 'Asset created', assetId: result.recordset[0].AssetId }); } catch (err) { if (String(err.message || '').includes('UNIQUE')) { return res.status(409).json({ success: false, message: 'Asset code already exists' }); } res.status(500).json({ success: false, message: err.message }); } }); app.put('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { try { const payload = normalizeAssetPayload(req.body); const updatedBy = getUserIdFromRequest(req); const exportedBy = await getUserDisplayNameById(updatedBy); if (!payload.assetCode || !payload.assetName) { return res.status(400).json({ success: false, message: 'Asset code and asset name are required' }); } await ensureDepartmentExists(payload.department); await pool.request() .input('assetId', sql.Int, req.params.id) .input('assetCode', sql.NVarChar, payload.assetCode) .input('assetName', sql.NVarChar, payload.assetName) .input('model', sql.NVarChar, payload.model) .input('serialNumber', sql.NVarChar, payload.serialNumber) .input('quantity', sql.Int, payload.quantity) .input('importInPeriod', sql.Int, payload.importInPeriod) .input('exportInPeriod', sql.Int, payload.exportInPeriod) .input('endingBalance', sql.Int, payload.endingBalance) .input('unit', sql.NVarChar, payload.unit) .input('department', sql.NVarChar, payload.department) .input('project', sql.NVarChar, payload.project) .input('location', sql.NVarChar, payload.location) .input('custodian', sql.NVarChar, payload.custodian) .input('borrower', sql.NVarChar, payload.borrower) .input('exportedBy', sql.NVarChar, exportedBy) .input('purchaseDate', sql.Date, payload.purchaseDate) .input('purchasePrice', sql.Decimal(18, 2), payload.purchasePrice) .input('status', sql.NVarChar, payload.status) .input('notes', sql.NVarChar, payload.notes) .query(` UPDATE AssetInventory SET AssetCode = @assetCode, AssetName = @assetName, Model = @model, SerialNumber = @serialNumber, Quantity = @quantity, ImportInPeriod = @importInPeriod, ExportInPeriod = @exportInPeriod, EndingBalance = @endingBalance, Unit = @unit, Department = @department, Project = @project, Location = @location, Custodian = @custodian, Borrower = @borrower, ExportedBy = @exportedBy, PurchaseDate = @purchaseDate, PurchasePrice = @purchasePrice, Status = @status, Notes = @notes, UpdatedDate = GETDATE() WHERE AssetId = @assetId `); res.json({ success: true, message: 'Asset updated' }); } catch (err) { if (String(err.message || '').includes('UNIQUE')) { return res.status(409).json({ success: false, message: 'Asset code already exists' }); } res.status(500).json({ success: false, message: err.message }); } }); app.delete('/api/assets/:id', requireAssetOrAdmin, async (req, res) => { try { await pool.request() .input('assetId', sql.Int, req.params.id) .query('DELETE FROM AssetInventory WHERE AssetId = @assetId'); res.json({ success: true, message: 'Asset deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async (req, res) => { let incomingRows = []; let source = 'rows'; let parseDiagnostics = []; try { if (req.file?.buffer) { const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); if (!workbook.SheetNames?.length) { return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' }); } const parsed = parseAssetImportRowsFromWorkbook(workbook); incomingRows = parsed.rows; parseDiagnostics = parsed.diagnostics; source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file'; } else { incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : []; } } catch (err) { return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` }); } if (!incomingRows.length) { if (req.file) { console.warn('Asset import parse returned 0 rows', { diagnostics: parseDiagnostics }); } return res.status(400).json({ success: false, message: req.file ? 'Khong tim thay dong du lieu hop le trong file Excel. Vui long kiem tra dong STT va du lieu cot ten/model/ton cuoi ky.' : 'Import data is empty', diagnostics: req.file ? parseDiagnostics : undefined }); } const createdBy = getUserIdFromRequest(req); const exportedBy = await getUserDisplayNameById(createdBy); const normalizedRows = incomingRows .map((row, rowIndex) => { const normalized = normalizeAssetPayload(row); if (!normalized.assetCode && normalized.assetName) { normalized.assetCode = generateImportAssetCodeFromRow(normalized, rowIndex + 1); } return normalized; }) .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(); await syncAssetDepartmentsFromInventory(); res.json({ success: true, message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`, data: { source, totalReceived: incomingRows.length, processed: normalizedRows.length, inserted, updated } }); } catch (err) { try { await transaction.rollback(); } catch (rollbackErr) { // Ignore rollback errors if transaction is already completed. } res.status(500).json({ success: false, message: err.message }); } }); // ========================================== // API ROUTES - Database Info // ========================================== // Get database information app.get('/api/database/info', async (req, res) => { try { const tables = await pool.request().query(` SELECT TABLE_NAME as TableName, (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = t.TABLE_NAME) as ColumnCount FROM INFORMATION_SCHEMA.TABLES t WHERE TABLE_SCHEMA = 'dbo' ORDER BY TABLE_NAME `); const users = await pool.request().query('SELECT COUNT(*) as Count FROM Users'); const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications'); const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts'); const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory'); res.json({ success: true, database: 'AccManager', server: '172.20.235.176', tables: tables.recordset, statistics: { users: users.recordset[0].Count, applications: apps.recordset[0].Count, accounts: accounts.recordset[0].Count, assets: assets.recordset[0].Count } }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); // Health check app.get('/api/health', (req, res) => { res.json({ status: 'OK', database: 'Connected' }); }); // ========================================== // Error Handling // ========================================== app.use((err, req, res, next) => { console.error(err); res.status(500).json({ success: false, message: err.message }); }); // ========================================== // Server Startup // ========================================== async function startServer() { try { await initializeDatabase(); app.listen(PORT, () => { console.log(`\n========================================`); console.log(`AccManager Backend Server`); console.log(`========================================`); console.log(`[OK] Server running on http://localhost:${PORT}`); console.log('[OK] Database: AccManager'); console.log('[OK] Default admin: admin / admin'); console.log(`\nAPI Endpoints:`); console.log(` POST /api/auth/login`); console.log(` GET /api/database/info`); console.log(` GET /api/users`); console.log(` GET /api/applications`); console.log(` GET /api/accounts/user/:userId`); console.log(` GET /api/assets`); console.log(`========================================\n`); }); } catch (err) { console.error('Failed to start server:', err); process.exit(1); } } // Graceful shutdown process.on('SIGINT', async () => { console.log('\nShutting down...'); if (pool) { await pool.close(); } process.exit(0); }); startServer();