This commit is contained in:
2026-06-03 13:58:12 -05:00
commit 2f13b8c590
54 changed files with 5136 additions and 0 deletions

131
server/routes/admin.js Normal file
View File

@@ -0,0 +1,131 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { all, audit, now, one, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { isValidRole, roleCapabilities, rolePermissions, roles } = require('../services/accessControl');
const router = express.Router();
const userSelect = `
SELECT u.id, u.team_id, t.name AS team_name, u.name, u.email, u.role, u.status, u.created_at, u.updated_at
FROM users u
LEFT JOIN teams t ON t.id = u.team_id
`;
function publicAdminUser(id) {
return one(`${userSelect} WHERE u.id = ?`, [id]);
}
function validateRole(role) {
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
}
router.get('/settings', requireRole('admin'), (req, res) => {
const rows = all('SELECT * FROM app_settings ORDER BY key');
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
});
router.put('/settings', requireRole('admin'), (req, res) => {
for (const [key, value] of Object.entries(req.body.settings || req.body)) {
run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [
key,
String(value),
now()
]);
}
audit(req.user.id, 'update', 'settings', 'app', null, req.body);
res.json({ settings: Object.fromEntries(all('SELECT * FROM app_settings ORDER BY key').map((row) => [row.key, row.value])) });
});
router.get('/users', requireRole('admin'), (req, res) => {
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
});
router.post('/users', requireRole('admin'), (req, res) => {
validateRole(req.body.role || 'viewer');
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
const created = now();
const result = run(
'INSERT INTO users (team_id, name, email, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.team_id || one('SELECT id FROM teams ORDER BY id LIMIT 1')?.id,
req.body.name,
req.body.email,
bcrypt.hashSync(req.body.password || 'ChangeMe123!', 12),
req.body.role || 'viewer',
req.body.status || 'active',
created,
created
]
);
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
});
router.put('/users/:id', requireRole('admin'), (req, res) => {
const before = publicAdminUser(req.params.id);
if (!before) return res.status(404).json({ error: 'User not found' });
const nextRole = req.body.role || before.role;
validateRole(nextRole);
const nextStatus = req.body.status || before.status;
if (!['active', 'inactive'].includes(nextStatus)) return res.status(400).json({ error: 'Invalid status' });
const activeAdmins = one("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND status = 'active'").count;
if (before.role === 'admin' && before.status === 'active' && activeAdmins <= 1 && (nextRole !== 'admin' || nextStatus !== 'active')) {
return res.status(400).json({ error: 'At least one active admin is required' });
}
const updated = now();
run(
`UPDATE users SET
team_id = ?,
name = ?,
email = ?,
role = ?,
status = ?,
updated_at = ?
WHERE id = ?`,
[
req.body.team_id ?? before.team_id,
req.body.name || before.name,
req.body.email || before.email,
nextRole,
nextStatus,
updated,
req.params.id
]
);
if (req.body.password) {
run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [
bcrypt.hashSync(req.body.password, 12),
updated,
req.params.id
]);
}
const user = publicAdminUser(req.params.id);
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
return res.json({ user });
});
router.get('/teams', requireRole('admin'), (req, res) => {
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
});
router.post('/teams', requireRole('admin'), (req, res) => {
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
req.body.name,
req.body.description || null,
now()
]);
audit(req.user.id, 'create', 'team', result.lastInsertRowid, null, req.body);
res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/access-control', requireRole('admin'), (req, res) => {
res.json({ roles, rolePermissions, roleCapabilities });
});
module.exports = router;

83
server/routes/assets.js Normal file
View File

@@ -0,0 +1,83 @@
const express = require('express');
const multer = require('multer');
const { audit, now, one, run } = require('../db');
const { annualSchedule } = require('../depreciation');
const { requireRole } = require('../middleware/auth');
const {
createAsset,
deleteAsset,
hydrateAsset,
listAssets,
massUpdateAssets,
updateAsset
} = require('../services/assets');
const { activeRuleSet } = require('../services/reports');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/assets', (req, res) => {
res.json({ assets: listAssets(req.query) });
});
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
});
router.get('/assets/:id', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.put('/assets/:id', requireRole('admin', 'finance', 'operations'), (req, res) => {
const asset = updateAsset(req.params.id, req.body, req.user.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.delete('/assets/:id', requireRole('admin', 'finance'), (req, res) => {
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
return res.status(204).end();
});
router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
return res.json({ updated });
});
router.get('/assets/:id/depreciation', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const rules = activeRuleSet();
const startYear = Number(req.query.startYear || new Date().getFullYear());
const endYear = Number(req.query.endYear || startYear + 5);
const schedules = asset.books
.filter((book) => book.active)
.flatMap((book) => annualSchedule(asset, book, rules, { startYear, endYear }));
res.json({ assetId: asset.asset_id, schedules });
});
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const result = run(
'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.file.originalname, req.file.mimetype, req.file.size, req.file.buffer.toString('base64'), req.user.id, now()]
);
audit(req.user.id, 'upload_file', 'asset', req.params.id, null, { file: req.file.originalname });
res.status(201).json({ file: one('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE id = ?', [result.lastInsertRowid]) });
});
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.body.title, req.body.type || 'maintenance', req.body.status || 'open', req.body.due_date || null, req.body.assigned_to || null, req.body.notes || null, created, created]
);
audit(req.user.id, 'create', 'task', result.lastInsertRowid, null, req.body);
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const {
assignAsset,
assignmentRows,
assignmentSummary,
listEmployees,
releaseAssignment,
upsertEmployee
} = require('../services/assignments');
const router = express.Router();
router.get('/employees', (req, res) => {
res.json({ employees: listEmployees(req.query) });
});
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
});
router.get('/assignments', (req, res) => {
res.json({
summary: assignmentSummary(),
assignments: assignmentRows({
asset_id: req.query.asset_id || null,
employee_id: req.query.employee_id || null,
active: req.query.active === 'true'
})
});
});
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
try {
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => {
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
return res.json({ assignment });
});
module.exports = router;

26
server/routes/auth.js Normal file
View File

@@ -0,0 +1,26 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { audit, one } = require('../db');
const { publicUser, tokenFor } = require('../middleware/auth');
const router = express.Router();
router.get('/public/bootstrap', (req, res) => {
res.json({
appName: 'MixedAssets',
setupComplete: Boolean(one('SELECT id FROM users LIMIT 1')),
database: 'sqlite'
});
});
router.post('/auth/login', (req, res) => {
const { email, password } = req.body;
const user = one('SELECT * FROM users WHERE lower(email) = lower(?) AND status = ?', [email || '', 'active']);
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
return res.status(401).json({ error: 'Invalid email or password' });
}
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
return res.json({ token: tokenFor(user), user: publicUser(user) });
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const { all, one } = require('../db');
const router = express.Router();
router.get('/dashboard', (req, res) => {
const stats = one(`
SELECT
COUNT(*) AS asset_count,
COALESCE(SUM(acquisition_cost), 0) AS total_cost,
COALESCE(SUM(CASE WHEN status = 'disposed' THEN 1 ELSE 0 END), 0) AS disposed_count,
COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
FROM assets
`);
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets GROUP BY category ORDER BY value DESC');
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets GROUP BY status ORDER BY count DESC');
const upcomingTasks = all(`
SELECT t.*, a.asset_id, a.description
FROM tasks t
LEFT JOIN assets a ON a.id = t.asset_id
WHERE t.status != 'complete'
ORDER BY t.due_date IS NULL, t.due_date
LIMIT 8
`);
const auditTrail = all('SELECT al.*, u.name AS actor_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id ORDER BY al.id DESC LIMIT 10');
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
});
module.exports = router;

View File

@@ -0,0 +1,27 @@
const express = require('express');
const multer = require('multer');
const { audit } = require('../db');
const { requireRole } = require('../middleware/auth');
const { upsertImportedAssets } = require('../services/assets');
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/export/assets', async (req, res) => {
const format = String(req.query.format || 'json').toLowerCase();
const output = await assetExport(format);
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
return res.send(output.body);
});
router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
const imported = upsertImportedAssets(rows, req.user.id);
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
res.json({ imported });
});
module.exports = router;

17
server/routes/profile.js Normal file
View File

@@ -0,0 +1,17 @@
const express = require('express');
const { audit } = require('../db');
const { getProfile, savePreferences } = require('../services/profile');
const router = express.Router();
router.get('/profile', (req, res) => {
res.json(getProfile(req.user));
});
router.put('/profile', (req, res) => {
const preferences = savePreferences(req.user.id, req.body.preferences || {});
audit(req.user.id, 'update', 'profile_preferences', req.user.id, null, { preferences });
res.json({ user: getProfile(req.user).user, preferences });
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/entities', (req, res) => {
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
});
router.post('/entities', requireRole('admin', 'finance'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
req.body.name,
req.body.legal_name || null,
req.body.tax_id || null,
Number(req.body.fiscal_year_end_month || 12),
req.body.base_currency || 'USD',
created,
created
]
);
audit(req.user.id, 'create', 'entity', result.lastInsertRowid, null, req.body);
res.status(201).json({ entity: one('SELECT * FROM entities WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/references', (req, res) => {
const rows = all('SELECT * FROM reference_values ORDER BY type, name');
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
});
module.exports = router;

28
server/routes/reports.js Normal file
View File

@@ -0,0 +1,28 @@
const express = require('express');
const {
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
} = require('../services/reports');
const router = express.Router();
router.get('/reports/depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(depreciationReport(year, bookType));
});
router.get('/reports/monthly-depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(monthlyDepreciationReport(year, bookType));
});
router.get('/reports/depreciation.pdf', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const book = req.query.book || 'GAAP';
writeDepreciationPdf(res, year, book);
});
module.exports = router;

34
server/routes/taxRules.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/tax-rules', (req, res) => {
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map((row) => ({
...row,
active: Boolean(row.active),
rules_json: parseJson(row.rules_json, {})
}));
res.json({ ruleSets });
});
router.post('/tax-rules', requireRole('admin', 'finance'), (req, res) => {
const result = run(
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.jurisdiction,
req.body.name,
req.body.effective_date,
req.body.version,
JSON.stringify(req.body.rules_json || req.body.rules || {}),
req.body.active === false ? 0 : 1,
req.body.source_note || null,
now()
]
);
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, req.body);
res.status(201).json({ ruleSet: one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

View File

@@ -0,0 +1,26 @@
const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/templates', (req, res) => {
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
...row,
defaults: parseJson(row.defaults, {}),
custom_fields: parseJson(row.custom_fields, [])
}));
res.json({ templates });
});
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
);
audit(req.user.id, 'create', 'asset_template', result.lastInsertRowid, null, req.body);
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

32
server/routes/workday.js Normal file
View File

@@ -0,0 +1,32 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const {
getConnection,
importWorkersFromPayload,
saveConnection,
syncWorkers
} = require('../services/workday');
const router = express.Router();
router.get('/workday/connection', requireRole('admin'), (req, res) => {
res.json({ connection: getConnection() });
});
router.put('/workday/connection', requireRole('admin'), (req, res) => {
res.json({ connection: saveConnection(req.body, req.user.id) });
});
router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => {
try {
res.json(await syncWorkers(req.user.id));
} catch (error) {
next(error);
}
});
router.post('/workday/import-workers', requireRole('admin'), (req, res) => {
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
});
module.exports = router;