Files
MixedAssets/server/routes/reference.js
2026-06-05 05:55:43 -05:00

447 lines
22 KiB
JavaScript

const express = require('express');
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 section179Limits = require('../services/section179Limits');
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();
});
// Annual Section 179 deduction limits & phaseout thresholds. GET is reference data; mutations
// require config.manage. The engine caps each asset's §179 deduction at the applicable year's limit.
router.get('/section-179-limits', (req, res) => {
res.json({ limits: section179Limits.list() });
});
router.post('/section-179-limits', requireCapability('config.manage'), (req, res, next) => {
try {
res.status(201).json({ limit: section179Limits.createLimit(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/section-179-limits/:year', requireCapability('config.manage'), (req, res, next) => {
try {
const limit = section179Limits.updateLimit(req.params.year, req.body, req.user.id);
if (!limit) return res.status(404).json({ error: 'Limit not found' });
return res.json({ limit });
} catch (error) {
return next(error);
}
});
router.delete('/section-179-limits/:year', requireCapability('config.manage'), (req, res) => {
if (!section179Limits.deleteLimit(req.params.year, req.user.id)) return res.status(404).json({ error: 'Limit 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', 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 (?, ?, ?, ?, ?, ?, ?)',
[
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, {}) })) });
});
// ---- 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;