Updated A LOT
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
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 { requireCapability } = require('../middleware/auth');
|
||||
const { CAPABILITIES } = require('../services/accessControl');
|
||||
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -17,15 +18,17 @@ function publicAdminUser(id) {
|
||||
}
|
||||
|
||||
function validateRole(role) {
|
||||
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
|
||||
if (!roleExists(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
|
||||
}
|
||||
|
||||
router.get('/settings', requireRole('admin'), (req, res) => {
|
||||
router.get('/settings', requireCapability('admin.settings'), (req, res) => {
|
||||
const rows = all('SELECT * FROM app_settings ORDER BY key');
|
||||
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
|
||||
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
delete settings.smtp_password; // managed (masked) via the notifications settings endpoint
|
||||
res.json({ settings });
|
||||
});
|
||||
|
||||
router.put('/settings', requireRole('admin'), (req, res) => {
|
||||
router.put('/settings', requireCapability('admin.settings'), (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,
|
||||
@@ -37,11 +40,11 @@ router.put('/settings', requireRole('admin'), (req, res) => {
|
||||
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) => {
|
||||
router.get('/users', requireCapability('admin.users'), (req, res) => {
|
||||
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
|
||||
});
|
||||
|
||||
router.post('/users', requireRole('admin'), (req, res) => {
|
||||
router.post('/users', requireCapability('admin.users'), (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();
|
||||
@@ -62,7 +65,7 @@ router.post('/users', requireRole('admin'), (req, res) => {
|
||||
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
||||
});
|
||||
|
||||
router.put('/users/:id', requireRole('admin'), (req, res) => {
|
||||
router.put('/users/:id', requireCapability('admin.users'), (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;
|
||||
@@ -110,11 +113,11 @@ router.put('/users/:id', requireRole('admin'), (req, res) => {
|
||||
return res.json({ user });
|
||||
});
|
||||
|
||||
router.get('/teams', requireRole('admin'), (req, res) => {
|
||||
router.get('/teams', requireCapability('admin.users'), (req, res) => {
|
||||
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
|
||||
});
|
||||
|
||||
router.post('/teams', requireRole('admin'), (req, res) => {
|
||||
router.post('/teams', requireCapability('admin.users'), (req, res) => {
|
||||
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
||||
req.body.name,
|
||||
req.body.description || null,
|
||||
@@ -124,8 +127,58 @@ router.post('/teams', requireRole('admin'), (req, res) => {
|
||||
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 });
|
||||
router.put('/teams/:id', requireCapability('admin.users'), (req, res) => {
|
||||
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Team not found' });
|
||||
run('UPDATE teams SET name = ?, description = ? WHERE id = ?', [
|
||||
req.body.name || before.name,
|
||||
req.body.description ?? before.description,
|
||||
req.params.id
|
||||
]);
|
||||
audit(req.user.id, 'update', 'team', req.params.id, before, req.body);
|
||||
return res.json({ team: one('SELECT * FROM teams WHERE id = ?', [req.params.id]) });
|
||||
});
|
||||
|
||||
router.delete('/teams/:id', requireCapability('admin.users'), (req, res) => {
|
||||
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Team not found' });
|
||||
const memberCount = one('SELECT COUNT(*) AS count FROM users WHERE team_id = ?', [req.params.id]).count;
|
||||
if (memberCount) {
|
||||
return res.status(400).json({ error: `Reassign the ${memberCount} user(s) on this team before deleting it` });
|
||||
}
|
||||
run('DELETE FROM teams WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'team', req.params.id, before, null);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/access-control', requireCapability('admin.users'), (req, res) => {
|
||||
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
|
||||
});
|
||||
|
||||
// ---- Roles (capability sets, custom roles allowed) --------------------------
|
||||
|
||||
router.get('/roles', requireCapability('admin.roles'), (req, res) => {
|
||||
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
|
||||
});
|
||||
|
||||
router.post('/roles', requireCapability('admin.roles'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ role: createRole(req.body, req.user.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/roles/:key', requireCapability('admin.roles'), (req, res) => {
|
||||
const role = updateRole(req.params.key, req.body, req.user.id);
|
||||
if (!role) return res.status(404).json({ error: 'Role not found' });
|
||||
return res.json({ role });
|
||||
});
|
||||
|
||||
router.delete('/roles/:key', requireCapability('admin.roles'), (req, res) => {
|
||||
const result = deleteRole(req.params.key, req.user.id);
|
||||
if (!result.ok) return res.status(result.status).json({ error: result.error });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
73
server/routes/alerts.js
Normal file
73
server/routes/alerts.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { listAlerts, runAlertCycle, scanAlerts, setStatus, summary } = require('../services/alerts');
|
||||
const { publicConfig, saveConfig, sendTestEmail } = require('../services/notifications');
|
||||
const { sendTestWebhook } = require('../services/webhooks');
|
||||
const { submitTicket } = require('../services/servicenow');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/alerts', (req, res) => {
|
||||
res.json({ alerts: listAlerts(req.query), summary: summary() });
|
||||
});
|
||||
|
||||
router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
|
||||
const result = scanAlerts(req.user.id);
|
||||
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({}), summary: summary() });
|
||||
});
|
||||
|
||||
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await runAlertCycle(req.user.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/alerts/:id/acknowledge', requireCapability('alerts.manage'), (req, res) => {
|
||||
const alert = setStatus(req.params.id, 'acknowledged', req.user.id);
|
||||
if (!alert) return res.status(404).json({ error: 'Alert not found' });
|
||||
return res.json({ alert });
|
||||
});
|
||||
|
||||
router.put('/alerts/:id/dismiss', requireCapability('alerts.manage'), (req, res) => {
|
||||
const alert = setStatus(req.params.id, 'dismissed', req.user.id);
|
||||
if (!alert) return res.status(404).json({ error: 'Alert not found' });
|
||||
return res.json({ alert });
|
||||
});
|
||||
|
||||
router.post('/alerts/:id/ticket', requireCapability('alerts.manage'), async (req, res, next) => {
|
||||
try {
|
||||
const ticket = await submitTicket(req.params.id, req.user.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Alert not found' });
|
||||
return res.json({ ticket });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ settings: publicConfig() });
|
||||
});
|
||||
|
||||
router.put('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ settings: saveConfig(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.post('/notifications/test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await sendTestEmail(req.body.to, req.user.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notifications/webhook-test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await sendTestWebhook(req.user.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,16 +2,20 @@ 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 { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
addAssetPhoto,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
deleteAssetPhoto,
|
||||
hydrateAsset,
|
||||
listAssets,
|
||||
lookupAssetByCode,
|
||||
massUpdateAssets,
|
||||
updateAsset
|
||||
updateAsset,
|
||||
updateAssetPhoto
|
||||
} = require('../services/assets');
|
||||
const { activeRuleSet } = require('../services/reports');
|
||||
const { ruleSetForBook } = require('../services/ruleSets');
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
||||
const router = express.Router();
|
||||
@@ -20,28 +24,36 @@ router.get('/assets', (req, res) => {
|
||||
res.json({ assets: listAssets(req.query) });
|
||||
});
|
||||
|
||||
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
|
||||
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.get('/assets/lookup', (req, res) => {
|
||||
const code = String(req.query.code || '').trim();
|
||||
if (!code) return res.status(400).json({ error: 'Scan code is required' });
|
||||
const asset = lookupAssetByCode(code);
|
||||
if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
|
||||
return res.json({ asset });
|
||||
});
|
||||
|
||||
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) => {
|
||||
router.put('/assets/:id', requireCapability('assets.edit'), (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) => {
|
||||
router.delete('/assets/:id', requireCapability('assets.delete'), (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) => {
|
||||
router.post('/assets/mass-update', requireCapability('assets.bulk'), (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' });
|
||||
@@ -51,16 +63,15 @@ router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) =
|
||||
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 }));
|
||||
.flatMap((book) => annualSchedule(asset, book, ruleSetForBook(book.book_type), { startYear, endYear }));
|
||||
res.json({ assetId: asset.asset_id, schedules });
|
||||
});
|
||||
|
||||
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.post('/assets/:id/files', upload.single('file'), requireCapability('assets.edit'), (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 (?, ?, ?, ?, ?, ?, ?)',
|
||||
@@ -78,14 +89,32 @@ router.get('/assets/:id/files/:fileId', (req, res) => {
|
||||
return res.send(Buffer.from(file.content_base64, 'base64'));
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/files/:fileId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.delete('/assets/:id/files/:fileId', requireCapability('assets.edit'), (req, res) => {
|
||||
const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]);
|
||||
if (!result.changes) return res.status(404).json({ error: 'File not found' });
|
||||
audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
// Identification photos (base64 data URLs, with optional captions)
|
||||
router.post('/assets/:id/photos', requireCapability('assets.edit'), (req, res) => {
|
||||
const photo = addAssetPhoto(req.params.id, req.body, req.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.status(201).json({ photo });
|
||||
});
|
||||
|
||||
router.put('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
|
||||
const photo = updateAssetPhoto(req.params.id, req.params.photoId, req.body, req.user.id);
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
return res.json({ photo });
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
|
||||
if (!deleteAssetPhoto(req.params.id, req.params.photoId, req.user.id)) return res.status(404).json({ error: 'Photo not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/assets/:id/warranties', requireCapability('assets.edit'), (req, res) => {
|
||||
const b = req.body;
|
||||
const result = run(
|
||||
`INSERT INTO warranties (
|
||||
@@ -107,14 +136,14 @@ router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operation
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/warranties/:warrantyId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.delete('/assets/:id/warranties/:warrantyId', requireCapability('assets.edit'), (req, res) => {
|
||||
const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]);
|
||||
if (!result.changes) return res.status(404).json({ error: 'Warranty not found' });
|
||||
audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.post('/assets/:id/tasks', requireCapability('assets.edit'), (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 (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
@@ -124,7 +153,7 @@ router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'),
|
||||
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
|
||||
});
|
||||
|
||||
router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.put('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
|
||||
const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Task not found' });
|
||||
run(
|
||||
@@ -143,14 +172,14 @@ router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operati
|
||||
return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) });
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.delete('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
|
||||
const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
||||
if (!result.changes) return res.status(404).json({ error: 'Task not found' });
|
||||
audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.post('/assets/:id/notes', requireCapability('assets.edit'), (req, res) => {
|
||||
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
||||
const result = run(
|
||||
'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)',
|
||||
@@ -165,7 +194,7 @@ router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'),
|
||||
});
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/notes/:noteId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.delete('/assets/:id/notes/:noteId', requireCapability('assets.edit'), (req, res) => {
|
||||
const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]);
|
||||
if (!result.changes) return res.status(404).json({ error: 'Note not found' });
|
||||
audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
assignAsset,
|
||||
assignmentRows,
|
||||
@@ -15,7 +15,7 @@ router.get('/employees', (req, res) => {
|
||||
res.json({ employees: listEmployees(req.query) });
|
||||
});
|
||||
|
||||
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
||||
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ router.get('/assignments', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
|
||||
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
|
||||
} catch (error) {
|
||||
@@ -38,7 +38,7 @@ router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req,
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
||||
router.put('/assignments/:id/release', requireCapability('assets.assign'), (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 });
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { audit, one } = require('../db');
|
||||
const { publicUser, tokenFor } = require('../middleware/auth');
|
||||
const { capabilitiesForRole } = require('../services/roles');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -20,7 +21,7 @@ router.post('/auth/login', (req, res) => {
|
||||
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) });
|
||||
return res.json({ token: tokenFor(user), user: publicUser(user), capabilities: capabilitiesForRole(user.role) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
46
server/routes/books.js
Normal file
46
server/routes/books.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
|
||||
const { exportReport } = require('../services/reportExport');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/books', (req, res) => {
|
||||
res.json(listBooks());
|
||||
});
|
||||
|
||||
router.post('/books', requireCapability('finance.manage'), (req, res) => {
|
||||
res.status(201).json({ book: createBook(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
const book = updateBook(req.params.id, req.body, req.user.id);
|
||||
if (!book) return res.status(404).json({ error: 'Book not found' });
|
||||
return res.json({ book });
|
||||
});
|
||||
|
||||
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
if (!deleteBook(req.params.id, req.user.id)) return res.status(404).json({ error: 'Book not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/books/:code/ledger', (req, res) => {
|
||||
const year = Number(req.query.year) || new Date().getFullYear();
|
||||
const ledger = bookLedger(req.params.code, year);
|
||||
if (!ledger) return res.status(404).json({ error: 'Book not found' });
|
||||
return res.json({ ledger });
|
||||
});
|
||||
|
||||
router.post('/books/:code/ledger/export', async (req, res) => {
|
||||
const year = Number(req.body.year) || new Date().getFullYear();
|
||||
const report = ledgerReport(req.params.code, year);
|
||||
if (!report) return res.status(404).json({ error: 'Book not found' });
|
||||
const format = String(req.body.format || 'csv').toLowerCase();
|
||||
const output = await exportReport(report, format);
|
||||
const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt';
|
||||
res.setHeader('Content-Type', output.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.params.code}-ledger-${year}.${extension}"`);
|
||||
return res.send(output.body);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
53
server/routes/contacts.js
Normal file
53
server/routes/contacts.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
addNote,
|
||||
createContact,
|
||||
deleteContact,
|
||||
deleteNote,
|
||||
getContact,
|
||||
listContacts,
|
||||
updateContact
|
||||
} = require('../services/contacts');
|
||||
|
||||
const router = express.Router();
|
||||
const editor = requireCapability('contacts.manage');
|
||||
|
||||
router.get('/contacts', (req, res) => {
|
||||
res.json({ contacts: listContacts(req.query) });
|
||||
});
|
||||
|
||||
router.get('/contacts/:id', (req, res) => {
|
||||
const contact = getContact(req.params.id);
|
||||
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||
return res.json({ contact });
|
||||
});
|
||||
|
||||
router.post('/contacts', editor, (req, res) => {
|
||||
res.status(201).json({ contact: createContact(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/contacts/:id', editor, (req, res) => {
|
||||
const contact = updateContact(req.params.id, req.body, req.user.id);
|
||||
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||
return res.json({ contact });
|
||||
});
|
||||
|
||||
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => {
|
||||
if (!deleteContact(req.params.id, req.user.id)) return res.status(404).json({ error: 'Contact not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/contacts/:id/notes', editor, (req, res) => {
|
||||
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
||||
const note = addNote(req.params.id, req.body.body, req.user.id);
|
||||
if (!note) return res.status(404).json({ error: 'Contact not found' });
|
||||
return res.status(201).json({ note });
|
||||
});
|
||||
|
||||
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => {
|
||||
if (!deleteNote(req.params.id, req.params.noteId, req.user.id)) return res.status(404).json({ error: 'Note not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { audit } = require('../db');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { upsertImportedAssets } = require('../services/assets');
|
||||
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
|
||||
|
||||
@@ -16,7 +16,7 @@ router.get('/export/assets', async (req, res) => {
|
||||
return res.send(output.body);
|
||||
});
|
||||
|
||||
router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => {
|
||||
router.post('/import/assets', requireCapability('assets.bulk'), 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);
|
||||
|
||||
44
server/routes/disposals.js
Normal file
44
server/routes/disposals.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
deleteAdjustment,
|
||||
previewDisposal,
|
||||
recordAdjustment,
|
||||
recordDisposal,
|
||||
reverseDisposal
|
||||
} = require('../services/disposals');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/assets/:id/disposal/preview', (req, res) => {
|
||||
const preview = previewDisposal(req.params.id, req.body);
|
||||
if (!preview) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.json({ preview });
|
||||
});
|
||||
|
||||
router.post('/assets/:id/disposals', requireCapability('finance.manage'), (req, res) => {
|
||||
const result = recordDisposal(req.params.id, req.body, req.user.id);
|
||||
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/disposals/:disposalId', requireCapability('finance.manage'), (req, res) => {
|
||||
const asset = reverseDisposal(req.params.id, req.params.disposalId, req.user.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Disposal not found' });
|
||||
return res.json({ asset });
|
||||
});
|
||||
|
||||
router.post('/assets/:id/adjustments', requireCapability('finance.manage'), (req, res) => {
|
||||
const adjustment = recordAdjustment(req.params.id, req.body, req.user.id);
|
||||
if (!adjustment) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.status(201).json({ adjustment });
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/adjustments/:adjustmentId', requireCapability('finance.manage'), (req, res) => {
|
||||
if (!deleteAdjustment(req.params.id, req.params.adjustmentId, req.user.id)) {
|
||||
return res.status(404).json({ error: 'Adjustment not found' });
|
||||
}
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
39
server/routes/leases.js
Normal file
39
server/routes/leases.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
createLease,
|
||||
deleteLease,
|
||||
getLease,
|
||||
leaseSchedule,
|
||||
listLeases,
|
||||
updateLease
|
||||
} = require('../services/leases');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/leases', (req, res) => {
|
||||
res.json({ leases: listLeases(req.query) });
|
||||
});
|
||||
|
||||
router.post('/leases', requireCapability('finance.manage'), (req, res) => {
|
||||
res.status(201).json({ lease: createLease(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/leases/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
const lease = updateLease(req.params.id, req.body, req.user.id);
|
||||
if (!lease) return res.status(404).json({ error: 'Lease not found' });
|
||||
return res.json({ lease });
|
||||
});
|
||||
|
||||
router.delete('/leases/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
if (!deleteLease(req.params.id, req.user.id)) return res.status(404).json({ error: 'Lease not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/leases/:id/schedule', (req, res) => {
|
||||
const lease = getLease(req.params.id);
|
||||
if (!lease) return res.status(404).json({ error: 'Lease not found' });
|
||||
return res.json({ lease, schedule: leaseSchedule(lease) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
77
server/routes/parts.js
Normal file
77
server/routes/parts.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
addPhoto,
|
||||
createPart,
|
||||
deletePart,
|
||||
deletePhoto,
|
||||
deleteStock,
|
||||
getPart,
|
||||
listParts,
|
||||
recordTransaction,
|
||||
saveStock,
|
||||
updatePart
|
||||
} = require('../services/parts');
|
||||
|
||||
const router = express.Router();
|
||||
const editor = requireCapability('parts.manage');
|
||||
|
||||
router.get('/parts', (req, res) => {
|
||||
res.json({ parts: listParts(req.query) });
|
||||
});
|
||||
|
||||
router.get('/parts/:id', (req, res) => {
|
||||
const part = getPart(req.params.id);
|
||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||
return res.json({ part });
|
||||
});
|
||||
|
||||
router.post('/parts', editor, (req, res) => {
|
||||
res.status(201).json({ part: createPart(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/parts/:id', editor, (req, res) => {
|
||||
const part = updatePart(req.params.id, req.body, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||
return res.json({ part });
|
||||
});
|
||||
|
||||
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => {
|
||||
if (!deletePart(req.params.id, req.user.id)) return res.status(404).json({ error: 'Part not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// Stock locations (aisle/bin + on-hand/reserved per location)
|
||||
router.post('/parts/:id/stock', editor, (req, res) => {
|
||||
const part = saveStock(req.params.id, req.body, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
|
||||
return res.status(201).json({ part });
|
||||
});
|
||||
|
||||
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => {
|
||||
const part = deleteStock(req.params.id, req.params.stockId, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Stock location not found' });
|
||||
return res.json({ part });
|
||||
});
|
||||
|
||||
// Stock movements (receive/issue/adjust/reserve/unreserve)
|
||||
router.post('/parts/:id/transactions', editor, (req, res) => {
|
||||
const part = recordTransaction(req.params.id, req.body, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||
return res.status(201).json({ part });
|
||||
});
|
||||
|
||||
// Photos
|
||||
router.post('/parts/:id/photos', editor, (req, res) => {
|
||||
const part = addPhoto(req.params.id, req.body, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||
return res.status(201).json({ part });
|
||||
});
|
||||
|
||||
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => {
|
||||
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id);
|
||||
if (!part) return res.status(404).json({ error: 'Photo not found' });
|
||||
return res.json({ part });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
96
server/routes/pm.js
Normal file
96
server/routes/pm.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
assetPmRows,
|
||||
attachPlan,
|
||||
completePm,
|
||||
createPlan,
|
||||
deletePlan,
|
||||
detachAssetPm,
|
||||
getCompletion,
|
||||
getPlan,
|
||||
getSettings,
|
||||
listCompletions,
|
||||
listPlans,
|
||||
saveSettings,
|
||||
updateAssetPm,
|
||||
updatePlan
|
||||
} = require('../services/pm');
|
||||
|
||||
const router = express.Router();
|
||||
const planEditor = requireCapability('pm.manage'); // create/edit PM plan templates & defaults
|
||||
const assetPm = requireCapability('pm.complete'); // attach to assets & complete services
|
||||
|
||||
// PM plan templates
|
||||
router.get('/pm-plans', (req, res) => {
|
||||
res.json({ plans: listPlans() });
|
||||
});
|
||||
|
||||
router.get('/pm-plans/:id', (req, res) => {
|
||||
const plan = getPlan(req.params.id);
|
||||
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
|
||||
return res.json({ plan });
|
||||
});
|
||||
|
||||
router.post('/pm-plans', planEditor, (req, res) => {
|
||||
res.status(201).json({ plan: createPlan(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/pm-plans/:id', planEditor, (req, res) => {
|
||||
const plan = updatePlan(req.params.id, req.body, req.user.id);
|
||||
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
|
||||
return res.json({ plan });
|
||||
});
|
||||
|
||||
router.delete('/pm-plans/:id', planEditor, (req, res) => {
|
||||
if (!deletePlan(req.params.id, req.user.id)) return res.status(404).json({ error: 'PM plan not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// PM defaults (readable by any authenticated user; pm.manage can change)
|
||||
router.get('/pm-settings', (req, res) => {
|
||||
res.json({ settings: getSettings() });
|
||||
});
|
||||
|
||||
router.put('/pm-settings', planEditor, (req, res) => {
|
||||
res.json({ settings: saveSettings(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
// Completed PM forms
|
||||
router.get('/pm-completions', (req, res) => {
|
||||
res.json({ completions: listCompletions(req.query) });
|
||||
});
|
||||
|
||||
router.get('/pm-completions/:id', (req, res) => {
|
||||
const completion = getCompletion(req.params.id);
|
||||
if (!completion) return res.status(404).json({ error: 'Completion not found' });
|
||||
return res.json({ completion });
|
||||
});
|
||||
|
||||
// Per-asset PM schedules
|
||||
router.get('/assets/:id/pm', (req, res) => {
|
||||
res.json({ pm: assetPmRows(req.params.id) });
|
||||
});
|
||||
|
||||
router.post('/assets/:id/pm', assetPm, (req, res) => {
|
||||
res.status(201).json({ pm: attachPlan(req.params.id, req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.put('/assets/:id/pm/:apId', assetPm, (req, res) => {
|
||||
const pm = updateAssetPm(req.params.id, req.params.apId, req.body, req.user.id);
|
||||
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
|
||||
return res.json({ pm });
|
||||
});
|
||||
|
||||
router.delete('/assets/:id/pm/:apId', assetPm, (req, res) => {
|
||||
if (!detachAssetPm(req.params.id, req.params.apId, req.user.id)) return res.status(404).json({ error: 'PM schedule not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/assets/:id/pm/:apId/complete', assetPm, (req, res) => {
|
||||
const pm = completePm(req.params.id, req.params.apId, req.body, req.user.id);
|
||||
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
|
||||
return res.json({ pm });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,14 +1,75 @@
|
||||
const express = require('express');
|
||||
const { all, audit, now, one, parseJson, run } = require('../db');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { formatAssetId } = require('../services/assets');
|
||||
const assetClasses = require('../services/assetClasses');
|
||||
const zones = require('../services/zones');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Special depreciation zones (e.g. New York Liberty Zone). GET is reference data used by the
|
||||
// asset form; mutations require config.manage.
|
||||
router.get('/depreciation-zones', (req, res) => {
|
||||
res.json({ zones: zones.list() });
|
||||
});
|
||||
|
||||
router.post('/depreciation-zones', requireCapability('config.manage'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ zone: zones.createZone(req.body, req.user.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/depreciation-zones/:code', requireCapability('config.manage'), (req, res, next) => {
|
||||
try {
|
||||
const zone = zones.updateZone(req.params.code, req.body, req.user.id);
|
||||
if (!zone) return res.status(404).json({ error: 'Zone not found' });
|
||||
return res.json({ zone });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (req, res) => {
|
||||
if (!zones.deleteZone(req.params.code, req.user.id)) return res.status(404).json({ error: 'Zone not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
|
||||
// data used by the category picker; mutations require config.manage.
|
||||
router.get('/asset-classes', (req, res) => {
|
||||
res.json({ assetClasses: assetClasses.list() });
|
||||
});
|
||||
|
||||
router.post('/asset-classes', requireCapability('config.manage'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ assetClass: assetClasses.createClass(req.body, req.user.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/asset-classes/:id', requireCapability('config.manage'), (req, res, next) => {
|
||||
try {
|
||||
const assetClass = assetClasses.updateClass(req.params.id, req.body, req.user.id);
|
||||
if (!assetClass) return res.status(404).json({ error: 'Asset class not found' });
|
||||
return res.json({ assetClass });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, res) => {
|
||||
if (!assetClasses.deleteClass(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset class not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/entities', (req, res) => {
|
||||
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
|
||||
});
|
||||
|
||||
router.post('/entities', requireRole('admin', 'finance'), (req, res) => {
|
||||
router.post('/entities', requireCapability('config.manage'), (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 (?, ?, ?, ?, ?, ?, ?)',
|
||||
@@ -31,4 +92,325 @@ router.get('/references', (req, res) => {
|
||||
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
||||
});
|
||||
|
||||
// ---- Asset categories (managed reference_values of type 'category') ----------
|
||||
// Asset category is stored on each asset by name, so a rename cascades to assets.
|
||||
|
||||
function categoryRow(row) {
|
||||
const meta = parseJson(row.metadata, {});
|
||||
const templateId = meta.id_template_id || null;
|
||||
const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
asset_class_number: meta.asset_class_number || null,
|
||||
id_template_id: template ? templateId : null,
|
||||
id_template_name: template ? template.name : null,
|
||||
id_template_pattern: template ? template.pattern : null,
|
||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [row.name]).c
|
||||
};
|
||||
}
|
||||
|
||||
const categoryEditor = requireCapability('config.manage');
|
||||
|
||||
router.get('/asset-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name");
|
||||
res.json({ categories: rows.map(categoryRow) });
|
||||
});
|
||||
|
||||
router.post('/asset-categories', categoryEditor, (req, res) => {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That category already exists' });
|
||||
}
|
||||
const result = run(
|
||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
|
||||
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_number: req.body.asset_class_number || null })]
|
||||
);
|
||||
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
||||
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||
});
|
||||
|
||||
router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||
const name = String(req.body.name ?? before.name).trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That category already exists' });
|
||||
}
|
||||
const meta = parseJson(before.metadata, {});
|
||||
if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null;
|
||||
if (req.body.asset_class_number !== undefined) meta.asset_class_number = req.body.asset_class_number || null;
|
||||
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
||||
let renamed = 0;
|
||||
if (name !== before.name) {
|
||||
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||
}
|
||||
// The class number is shared by every asset in the category — cascade any change.
|
||||
if (req.body.asset_class_number !== undefined) {
|
||||
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
||||
}
|
||||
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
||||
return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||
});
|
||||
|
||||
router.delete('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [before.name]).c;
|
||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
|
||||
return res.json({ deleted: true, asset_count: affected });
|
||||
});
|
||||
|
||||
// ---- Asset departments (managed reference_values of type 'department') -------
|
||||
// Department is stored on each asset by name, so a rename cascades to assets.
|
||||
|
||||
function departmentRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/asset-departments', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name");
|
||||
res.json({ departments: rows.map(departmentRow) });
|
||||
});
|
||||
|
||||
router.post('/asset-departments', categoryEditor, (req, res) => {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That department already exists' });
|
||||
}
|
||||
const result = run(
|
||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')",
|
||||
[name, req.body.code || null]
|
||||
);
|
||||
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name });
|
||||
return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||
});
|
||||
|
||||
router.put('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||
const name = String(req.body.name ?? before.name).trim();
|
||||
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That department already exists' });
|
||||
}
|
||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||
let renamed = 0;
|
||||
if (name !== before.name) {
|
||||
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ?', [name, now(), before.name]).changes || 0;
|
||||
}
|
||||
audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed });
|
||||
return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||
});
|
||||
|
||||
router.delete('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [before.name]).c;
|
||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
|
||||
return res.json({ deleted: true, asset_count: affected });
|
||||
});
|
||||
|
||||
// ---- PM categories (managed reference_values of type 'pm_category') ----------
|
||||
// Separate from asset categories. Stored on each PM plan by name, so a rename cascades.
|
||||
|
||||
function pmCategoryRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/pm-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name");
|
||||
res.json({ categories: rows.map(pmCategoryRow) });
|
||||
});
|
||||
|
||||
router.post('/pm-categories', categoryEditor, (req, res) => {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That PM category already exists' });
|
||||
}
|
||||
const result = run(
|
||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')",
|
||||
[name, req.body.code || null]
|
||||
);
|
||||
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name });
|
||||
return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||
});
|
||||
|
||||
router.put('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||
const name = String(req.body.name ?? before.name).trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That PM category already exists' });
|
||||
}
|
||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||
let renamed = 0;
|
||||
if (name !== before.name) {
|
||||
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||
}
|
||||
audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed });
|
||||
return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed });
|
||||
});
|
||||
|
||||
router.delete('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [before.name]).c;
|
||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
|
||||
return res.json({ deleted: true, plan_count: affected });
|
||||
});
|
||||
|
||||
// ---- Part categories (managed reference_values of type 'part_category') ------
|
||||
// Separate from asset/PM categories. Stored on each part by name, so a rename cascades.
|
||||
|
||||
function partCategoryRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
part_count: one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [row.name]).c
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/part-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name");
|
||||
res.json({ categories: rows.map(partCategoryRow) });
|
||||
});
|
||||
|
||||
router.post('/part-categories', categoryEditor, (req, res) => {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That part category already exists' });
|
||||
}
|
||||
const result = run(
|
||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')",
|
||||
[name, req.body.code || null]
|
||||
);
|
||||
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name });
|
||||
return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||
});
|
||||
|
||||
router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||
const name = String(req.body.name ?? before.name).trim();
|
||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
||||
return res.status(400).json({ error: 'That part category already exists' });
|
||||
}
|
||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||
let renamed = 0;
|
||||
if (name !== before.name) {
|
||||
renamed = run('UPDATE parts SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||
}
|
||||
audit(req.user.id, 'update', 'part_category', req.params.id, before, { name, renamed_parts: renamed });
|
||||
return res.json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_parts: renamed });
|
||||
});
|
||||
|
||||
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||
const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
|
||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'part_category', req.params.id, before, { part_count: affected });
|
||||
return res.json({ deleted: true, part_count: affected });
|
||||
});
|
||||
|
||||
// ---- Asset ID templates -----------------------------------------------------
|
||||
// Naming patterns (e.g. 'DT-######') used to auto-assign asset tags. The first run of
|
||||
// '#' is replaced with a zero-padded, auto-incrementing number. Assigned to categories.
|
||||
|
||||
function idTemplateCategoryCount(templateId) {
|
||||
return all("SELECT metadata FROM reference_values WHERE type = 'category'")
|
||||
.filter((row) => parseJson(row.metadata, {}).id_template_id === templateId).length;
|
||||
}
|
||||
|
||||
function idTemplateRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
pattern: row.pattern,
|
||||
next_number: row.next_number,
|
||||
preview: formatAssetId(row.pattern, row.next_number),
|
||||
category_count: idTemplateCategoryCount(row.id)
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/asset-id-templates', (req, res) => {
|
||||
res.json({ templates: all('SELECT * FROM asset_id_templates ORDER BY name').map(idTemplateRow) });
|
||||
});
|
||||
|
||||
router.post('/asset-id-templates', categoryEditor, (req, res) => {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const pattern = String(req.body.pattern || '').trim();
|
||||
if (!name) return res.status(400).json({ error: 'A template name is required' });
|
||||
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
|
||||
const next = Math.max(1, Number(req.body.next_number) || 1);
|
||||
const ts = now();
|
||||
const result = run(
|
||||
'INSERT INTO asset_id_templates (name, pattern, next_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[name, pattern, next, ts, ts]
|
||||
);
|
||||
audit(req.user.id, 'create', 'asset_id_template', result.lastInsertRowid, null, { name, pattern });
|
||||
return res.status(201).json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [result.lastInsertRowid])) });
|
||||
});
|
||||
|
||||
router.put('/asset-id-templates/:id', categoryEditor, (req, res) => {
|
||||
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||
const pattern = req.body.pattern !== undefined ? String(req.body.pattern).trim() : before.pattern;
|
||||
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
|
||||
run(
|
||||
'UPDATE asset_id_templates SET name = ?, pattern = ?, next_number = ?, updated_at = ? WHERE id = ?',
|
||||
[
|
||||
req.body.name !== undefined ? String(req.body.name).trim() || before.name : before.name,
|
||||
pattern,
|
||||
req.body.next_number !== undefined ? Math.max(1, Number(req.body.next_number) || 1) : before.next_number,
|
||||
now(),
|
||||
req.params.id
|
||||
]
|
||||
);
|
||||
audit(req.user.id, 'update', 'asset_id_template', req.params.id, before, req.body);
|
||||
return res.json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id])) });
|
||||
});
|
||||
|
||||
router.delete('/asset-id-templates/:id', categoryEditor, (req, res) => {
|
||||
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||
const id = Number(req.params.id);
|
||||
// Unassign from any categories that referenced it.
|
||||
let unassigned = 0;
|
||||
for (const row of all("SELECT id, metadata FROM reference_values WHERE type = 'category'")) {
|
||||
const meta = parseJson(row.metadata, {});
|
||||
if (meta.id_template_id === id) {
|
||||
meta.id_template_id = null;
|
||||
run('UPDATE reference_values SET metadata = ? WHERE id = ?', [json(meta), row.id]);
|
||||
unassigned += 1;
|
||||
}
|
||||
}
|
||||
run('DELETE FROM asset_id_templates WHERE id = ?', [id]);
|
||||
audit(req.user.id, 'delete', 'asset_id_template', id, before, { unassigned_categories: unassigned });
|
||||
return res.json({ deleted: true, category_count: unassigned });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
const express = require('express');
|
||||
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
depreciationReport,
|
||||
monthlyDepreciationReport,
|
||||
writeDepreciationPdf
|
||||
} = require('../services/reports');
|
||||
const { catalog, runReport } = require('../services/reportEngine');
|
||||
const { exportReport } = require('../services/reportExport');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const EXPORT_EXTENSIONS = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf', json: 'json' };
|
||||
|
||||
router.get('/reports/catalog', (req, res) => {
|
||||
res.json(catalog());
|
||||
});
|
||||
|
||||
router.post('/reports/run', (req, res) => {
|
||||
res.json({ report: runReport(req.body.type, req.body.options || {}) });
|
||||
});
|
||||
|
||||
router.post('/reports/export', async (req, res) => {
|
||||
const format = String(req.body.format || 'csv').toLowerCase();
|
||||
const report = runReport(req.body.type, req.body.options || {});
|
||||
const output = await exportReport(report, format);
|
||||
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
||||
res.setHeader('Content-Type', output.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.body.type}.${extension}"`);
|
||||
res.send(output.body);
|
||||
});
|
||||
|
||||
router.get('/saved-reports', (req, res) => {
|
||||
const reports = all(
|
||||
`SELECT s.*, u.name AS created_by_name FROM saved_reports s
|
||||
LEFT JOIN users u ON u.id = s.created_by ORDER BY s.name`
|
||||
).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
|
||||
res.json({ reports });
|
||||
});
|
||||
|
||||
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
||||
const result = run(
|
||||
'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()]
|
||||
);
|
||||
audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
|
||||
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ report: { ...saved, options_json: parseJson(saved.options_json, {}) } });
|
||||
});
|
||||
|
||||
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
|
||||
const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]);
|
||||
if (!result.changes) return res.status(404).json({ error: 'Saved report not found' });
|
||||
audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.get('/reports/depreciation', (req, res) => {
|
||||
const year = Number(req.query.year || new Date().getFullYear());
|
||||
const bookType = req.query.book || 'GAAP';
|
||||
|
||||
31
server/routes/servicenow.js
Normal file
31
server/routes/servicenow.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const express = require('express');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { publicConfig, saveConfig, syncCmdb, testConnection } = require('../services/servicenow');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ settings: publicConfig() });
|
||||
});
|
||||
|
||||
router.put('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ settings: saveConfig(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.post('/servicenow/test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await testConnection(req.user.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/servicenow/cmdb-sync', requireCapability('assets.bulk'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await syncCmdb(req.user.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,34 +1,120 @@
|
||||
const express = require('express');
|
||||
const { all, audit, now, one, parseJson, run } = require('../db');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { all, audit, now, one, parseJson, run, tx } = require('../db');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { expandRuleSet } = require('../rule-catalog');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function coerceRules(value) {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
const error = new Error('rules_json is not valid JSON');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
const error = new Error('rules_json must be a JSON object');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(row) {
|
||||
return { ...row, active: Boolean(row.active), rules_json: parseJson(row.rules_json, {}) };
|
||||
}
|
||||
|
||||
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, {})
|
||||
}));
|
||||
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize);
|
||||
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]) });
|
||||
router.post('/tax-rules', requireCapability('finance.manage'), (req, res, next) => {
|
||||
try {
|
||||
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
|
||||
const result = run(
|
||||
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
req.body.jurisdiction || rules.jurisdiction || 'US-FED',
|
||||
req.body.name || rules.name || 'Untitled rule set',
|
||||
req.body.effective_date || rules.effectiveDate || rules.effective_date || new Date().toISOString().slice(0, 10),
|
||||
req.body.version || rules.version || '1.0',
|
||||
JSON.stringify(rules),
|
||||
req.body.active === false ? 0 : 1,
|
||||
req.body.source_note || rules.sourceNote || null,
|
||||
now()
|
||||
]
|
||||
);
|
||||
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, { ...req.body, rules_json: '[stored]' });
|
||||
res.status(201).json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid])) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/tax-rules/:id', requireCapability('finance.manage'), (req, res, next) => {
|
||||
try {
|
||||
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Rule set not found' });
|
||||
const rules = req.body.rules_json !== undefined || req.body.rules !== undefined
|
||||
? coerceRules(req.body.rules_json ?? req.body.rules)
|
||||
: parseJson(before.rules_json, {});
|
||||
run(
|
||||
`UPDATE tax_rule_sets SET
|
||||
jurisdiction = ?, name = ?, effective_date = ?, version = ?, rules_json = ?, active = ?, source_note = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
req.body.jurisdiction ?? before.jurisdiction,
|
||||
req.body.name ?? before.name,
|
||||
req.body.effective_date ?? before.effective_date,
|
||||
req.body.version ?? before.version,
|
||||
JSON.stringify(rules),
|
||||
req.body.active === undefined ? before.active : (req.body.active ? 1 : 0),
|
||||
req.body.source_note ?? before.source_note,
|
||||
req.params.id
|
||||
]
|
||||
);
|
||||
audit(req.user.id, 'update', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, { ...req.body, rules_json: '[stored]' });
|
||||
return res.json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id])) });
|
||||
} catch (error) {
|
||||
if (/UNIQUE/.test(error.message || '')) {
|
||||
return res.status(400).json({ error: 'A rule set with this jurisdiction and version already exists' });
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/tax-rules/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Rule set not found' });
|
||||
run('DELETE FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, null);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/tax-rules/:id/activate', requireCapability('finance.manage'), (req, res) => {
|
||||
const target = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'Rule set not found' });
|
||||
tx(() => {
|
||||
run('UPDATE tax_rule_sets SET active = 0 WHERE jurisdiction = ? AND id != ?', [target.jurisdiction, target.id]);
|
||||
run('UPDATE tax_rule_sets SET active = 1 WHERE id = ?', [target.id]);
|
||||
});
|
||||
audit(req.user.id, 'activate', 'tax_rule_set', target.id, null, { jurisdiction: target.jurisdiction });
|
||||
return res.json({ ruleSets: all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize) });
|
||||
});
|
||||
|
||||
// Merge the generated 68-method depreciation catalog into a rule set (works on unsaved drafts too).
|
||||
router.post('/tax-rules/expand', requireCapability('finance.manage'), (req, res, next) => {
|
||||
try {
|
||||
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
|
||||
return res.json({ rules_json: expandRuleSet(rules) });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
const express = require('express');
|
||||
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/templates', (req, res) => {
|
||||
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
|
||||
const templates = all(
|
||||
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count
|
||||
FROM asset_templates t ORDER BY t.name`
|
||||
).map((row) => ({
|
||||
...row,
|
||||
defaults: parseJson(row.defaults, {}),
|
||||
custom_fields: parseJson(row.custom_fields, [])
|
||||
@@ -13,7 +16,7 @@ router.get('/templates', (req, res) => {
|
||||
res.json({ templates });
|
||||
});
|
||||
|
||||
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
|
||||
router.post('/templates', requireCapability('config.manage'), (req, res) => {
|
||||
const created = now();
|
||||
const result = run(
|
||||
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
@@ -23,4 +26,34 @@ router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
|
||||
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
||||
});
|
||||
|
||||
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||
run(
|
||||
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?',
|
||||
[
|
||||
req.body.name ?? before.name,
|
||||
req.body.description ?? before.description,
|
||||
json(req.body.defaults || {}),
|
||||
json(req.body.custom_fields || []),
|
||||
now(),
|
||||
req.params.id
|
||||
]
|
||||
);
|
||||
audit(req.user.id, 'update', 'asset_template', req.params.id, before, req.body);
|
||||
return res.json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]) });
|
||||
});
|
||||
|
||||
// Deleting a template unlinks it from any assets (their stored field values are kept)
|
||||
// and removes it from the picker for future assets.
|
||||
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
|
||||
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);
|
||||
run('DELETE FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||
audit(req.user.id, 'delete', 'asset_template', req.params.id, before, { unlinked_assets: affected });
|
||||
return res.json({ deleted: true, unlinked_assets: affected });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const {
|
||||
getConnection,
|
||||
importWorkersFromPayload,
|
||||
@@ -9,15 +9,15 @@ const {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/workday/connection', requireRole('admin'), (req, res) => {
|
||||
router.get('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ connection: getConnection() });
|
||||
});
|
||||
|
||||
router.put('/workday/connection', requireRole('admin'), (req, res) => {
|
||||
router.put('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json({ connection: saveConnection(req.body, req.user.id) });
|
||||
});
|
||||
|
||||
router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => {
|
||||
router.post('/workday/sync-workers', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
try {
|
||||
res.json(await syncWorkers(req.user.id));
|
||||
} catch (error) {
|
||||
@@ -25,7 +25,7 @@ router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/workday/import-workers', requireRole('admin'), (req, res) => {
|
||||
router.post('/workday/import-workers', requireCapability('admin.integrations'), (req, res) => {
|
||||
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user