Added Multi-Company Support
This commit is contained in:
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const { DB_PATH, initialize } = require('./db');
|
||||
const { createCorsMiddleware } = require('./middleware/cors');
|
||||
const { requireAuth } = require('./middleware/auth');
|
||||
const { resolveCompany } = require('./middleware/company');
|
||||
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const alertRoutes = require('./routes/alerts');
|
||||
@@ -46,6 +47,7 @@ function createApp() {
|
||||
|
||||
app.use('/api', authRoutes);
|
||||
app.use('/api', requireAuth);
|
||||
app.use('/api', resolveCompany);
|
||||
app.use('/api', profileRoutes);
|
||||
app.use('/api', dashboardRoutes);
|
||||
app.use('/api', referenceRoutes);
|
||||
|
||||
295
server/db.js
295
server/db.js
@@ -108,6 +108,12 @@ function initialize() {
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_companies (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -115,17 +121,21 @@ function initialize() {
|
||||
tax_id TEXT,
|
||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||
base_currency TEXT NOT NULL DEFAULT 'USD',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
disposed_at TEXT,
|
||||
disposed_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reference_values (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT,
|
||||
metadata TEXT,
|
||||
UNIQUE(type, name)
|
||||
UNIQUE(entity_id, type, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
@@ -207,12 +217,14 @@ function initialize() {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
defaults TEXT NOT NULL,
|
||||
custom_fields TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tax_rule_sets (
|
||||
@@ -328,6 +340,7 @@ function initialize() {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
name TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
@@ -382,7 +395,8 @@ function initialize() {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
book_type TEXT NOT NULL DEFAULT 'tax',
|
||||
@@ -394,7 +408,8 @@ function initialize() {
|
||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
@@ -528,6 +543,7 @@ function initialize() {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
type TEXT NOT NULL DEFAULT 'other',
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
@@ -571,7 +587,8 @@ function initialize() {
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
part_number TEXT NOT NULL UNIQUE,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
part_number TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
@@ -588,7 +605,8 @@ function initialize() {
|
||||
notes TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, part_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS part_stock (
|
||||
@@ -759,6 +777,154 @@ function migrate() {
|
||||
ensureColumn('users', 'last_login_at', 'TEXT');
|
||||
ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
|
||||
dropUsersRoleCheck();
|
||||
// Multi-company scoping: company lifecycle + per-company foreign keys.
|
||||
ensureColumn('entities', 'status', "TEXT NOT NULL DEFAULT 'active'");
|
||||
ensureColumn('entities', 'disposed_at', 'TEXT');
|
||||
ensureColumn('entities', 'disposed_reason', 'TEXT');
|
||||
ensureColumn('pm_plans', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||
ensureColumn('alerts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||
rebuildBooksPerCompany();
|
||||
// Phase 2: scope reference data, contacts, templates, saved reports per company.
|
||||
ensureColumn('contacts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||
ensureColumn('saved_reports', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||
rebuildReferenceValuesPerCompany();
|
||||
rebuildAssetTemplatesPerCompany();
|
||||
rebuildPartsPerCompany();
|
||||
backfillCompanyScope();
|
||||
}
|
||||
|
||||
// parts became per-company: UNIQUE(part_number) → UNIQUE(entity_id, part_number).
|
||||
function rebuildPartsPerCompany() {
|
||||
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'parts'");
|
||||
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||
db.exec('PRAGMA foreign_keys=OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE parts_rebuild (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
part_number TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
manufacturer TEXT,
|
||||
manufacturer_part_number TEXT,
|
||||
barcode TEXT,
|
||||
unit_of_measure TEXT NOT NULL DEFAULT 'each',
|
||||
unit_cost REAL NOT NULL DEFAULT 0,
|
||||
reorder_point REAL NOT NULL DEFAULT 0,
|
||||
reorder_quantity REAL NOT NULL DEFAULT 0,
|
||||
measurements TEXT,
|
||||
supplier_contact_id INTEGER REFERENCES contacts(id),
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
notes TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, part_number)
|
||||
);
|
||||
INSERT INTO parts_rebuild (id, part_number, name, description, category, manufacturer, manufacturer_part_number, barcode, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, supplier_contact_id, status, notes, created_by, created_at, updated_at)
|
||||
SELECT id, part_number, name, description, category, manufacturer, manufacturer_part_number, barcode, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, supplier_contact_id, status, notes, created_by, created_at, updated_at FROM parts;
|
||||
DROP TABLE parts;
|
||||
ALTER TABLE parts_rebuild RENAME TO parts;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// reference_values became per-company: UNIQUE(type, name) → UNIQUE(entity_id, type, name).
|
||||
function rebuildReferenceValuesPerCompany() {
|
||||
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'reference_values'");
|
||||
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||
db.exec('PRAGMA foreign_keys=OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE reference_values_rebuild (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT,
|
||||
metadata TEXT,
|
||||
UNIQUE(entity_id, type, name)
|
||||
);
|
||||
INSERT INTO reference_values_rebuild (id, type, name, code, metadata)
|
||||
SELECT id, type, name, code, metadata FROM reference_values;
|
||||
DROP TABLE reference_values;
|
||||
ALTER TABLE reference_values_rebuild RENAME TO reference_values;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// asset_templates became per-company: UNIQUE(name) → UNIQUE(entity_id, name).
|
||||
function rebuildAssetTemplatesPerCompany() {
|
||||
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'asset_templates'");
|
||||
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||
db.exec('PRAGMA foreign_keys=OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE asset_templates_rebuild (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
defaults TEXT NOT NULL,
|
||||
custom_fields TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, name)
|
||||
);
|
||||
INSERT INTO asset_templates_rebuild (id, name, description, defaults, custom_fields, created_at, updated_at)
|
||||
SELECT id, name, description, defaults, custom_fields, created_at, updated_at FROM asset_templates;
|
||||
DROP TABLE asset_templates;
|
||||
ALTER TABLE asset_templates_rebuild RENAME TO asset_templates;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// Books became per-company: the old global UNIQUE(code) must become UNIQUE(entity_id, code).
|
||||
// SQLite can't alter a constraint in place, so rebuild when the legacy column-level UNIQUE is present.
|
||||
function rebuildBooksPerCompany() {
|
||||
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'");
|
||||
if (!ddl || !/code\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(ddl.sql)) return; // already composite
|
||||
db.exec('PRAGMA foreign_keys=OFF');
|
||||
db.exec(`
|
||||
CREATE TABLE books_rebuild (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
book_type TEXT NOT NULL DEFAULT 'tax',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
tax_rule_set_id INTEGER REFERENCES tax_rule_sets(id),
|
||||
default_method TEXT,
|
||||
default_convention TEXT,
|
||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(entity_id, code)
|
||||
);
|
||||
INSERT INTO books_rebuild (id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||
SELECT id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at FROM books;
|
||||
DROP TABLE books;
|
||||
ALTER TABLE books_rebuild RENAME TO books;
|
||||
`);
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// Assign pre-multi-company rows to the default (first active) company. No-op on a fresh DB.
|
||||
function backfillCompanyScope() {
|
||||
const def = one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1") || one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||
if (!def) return;
|
||||
// OR IGNORE so a pre-existing duplicate (e.g. two templates with the same name) can't break the
|
||||
// new per-company UNIQUE constraints — the duplicate is left unscoped rather than crashing init.
|
||||
for (const table of ['assets', 'books', 'pm_plans', 'alerts', 'reference_values', 'contacts', 'asset_templates', 'saved_reports', 'parts']) {
|
||||
run(`UPDATE OR IGNORE ${table} SET entity_id = ? WHERE entity_id IS NULL`, [def.id]);
|
||||
}
|
||||
// Preserve "everyone sees all" on upgrade: grant each existing user every existing company,
|
||||
// but only when the mapping is empty (first run) so deliberate later restrictions aren't undone.
|
||||
if (!one('SELECT 1 AS x FROM user_companies LIMIT 1')) {
|
||||
run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) SELECT u.id, e.id FROM users u CROSS JOIN entities e');
|
||||
}
|
||||
}
|
||||
|
||||
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
|
||||
@@ -787,6 +953,62 @@ function dropUsersRoleCheck() {
|
||||
db.exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
|
||||
// Seed the standard book set for a company (idempotent per company). Called during initial seed and
|
||||
// whenever a new company is created so it starts with GAAP/FEDERAL/STATE/AMT/ACE/USER + the PM book.
|
||||
function seedBooksForCompany(entityId) {
|
||||
if (!entityId || one('SELECT id FROM books WHERE entity_id = ? LIMIT 1', [entityId])) return;
|
||||
const createdAt = now();
|
||||
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
|
||||
const ruleId = federalRule ? federalRule.id : null;
|
||||
const bookDefs = [
|
||||
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
|
||||
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
|
||||
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
|
||||
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
|
||||
];
|
||||
bookDefs.forEach((def, index) => {
|
||||
run(
|
||||
`INSERT INTO books (entity_id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[entityId, def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
|
||||
);
|
||||
});
|
||||
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
|
||||
run(
|
||||
`INSERT OR IGNORE INTO books (entity_id, code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
|
||||
VALUES (?, 'PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
|
||||
[entityId, createdAt, createdAt]
|
||||
);
|
||||
}
|
||||
|
||||
// Seed the standard reference data (categories, locations, departments, part categories) for a company.
|
||||
// Idempotent per company; called during initial seed and when a new company is created.
|
||||
function seedReferenceValuesForCompany(entityId) {
|
||||
if (!entityId) return;
|
||||
const refs = [
|
||||
['location', 'Headquarters', 'HQ'],
|
||||
['location', 'Warehouse', 'WH'],
|
||||
['department', 'Accounting', 'ACCT'],
|
||||
['department', 'Operations', 'OPS'],
|
||||
['category', 'Computer Equipment', 'COMP'],
|
||||
['category', 'Office Furniture', 'FURN'],
|
||||
['category', 'Vehicles', 'AUTO'],
|
||||
['category', 'Leasehold Improvements', 'LHI'],
|
||||
['part_category', 'Electrical', null],
|
||||
['part_category', 'Plumbing', null],
|
||||
['part_category', 'Structural Component', null],
|
||||
['part_category', 'Electronic Component', null],
|
||||
['part_category', 'Fire Safety', null],
|
||||
['part_category', 'Mechanical Safety', null],
|
||||
['part_category', 'Other', null]
|
||||
];
|
||||
for (const item of refs) {
|
||||
run('INSERT OR IGNORE INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, ?, ?, ?, ?)', [entityId, item[0], item[1], item[2], '{}']);
|
||||
}
|
||||
}
|
||||
|
||||
function seed() {
|
||||
const createdAt = now();
|
||||
|
||||
@@ -915,31 +1137,8 @@ function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
const refs = [
|
||||
['location', 'Headquarters', 'HQ'],
|
||||
['location', 'Warehouse', 'WH'],
|
||||
['department', 'Accounting', 'ACCT'],
|
||||
['department', 'Operations', 'OPS'],
|
||||
['category', 'Computer Equipment', 'COMP'],
|
||||
['category', 'Office Furniture', 'FURN'],
|
||||
['category', 'Vehicles', 'AUTO'],
|
||||
['category', 'Leasehold Improvements', 'LHI'],
|
||||
['part_category', 'Electrical', null],
|
||||
['part_category', 'Plumbing', null],
|
||||
['part_category', 'Structural Component', null],
|
||||
['part_category', 'Electronic Component', null],
|
||||
['part_category', 'Fire Safety', null],
|
||||
['part_category', 'Mechanical Safety', null],
|
||||
['part_category', 'Other', null]
|
||||
];
|
||||
for (const item of refs) {
|
||||
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
|
||||
item[0],
|
||||
item[1],
|
||||
item[2],
|
||||
'{}'
|
||||
]);
|
||||
}
|
||||
const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||
if (refEntity) seedReferenceValuesForCompany(refEntity.id);
|
||||
|
||||
run(
|
||||
`INSERT OR IGNORE INTO employees (
|
||||
@@ -1005,32 +1204,10 @@ function seed() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!one('SELECT id FROM books LIMIT 1')) {
|
||||
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
|
||||
const ruleId = federalRule ? federalRule.id : null;
|
||||
const bookDefs = [
|
||||
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
|
||||
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
|
||||
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
|
||||
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
|
||||
];
|
||||
bookDefs.forEach((def, index) => {
|
||||
run(
|
||||
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
|
||||
run(
|
||||
`INSERT OR IGNORE INTO books (code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
|
||||
VALUES ('PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
|
||||
[createdAt, createdAt]
|
||||
);
|
||||
// Seed the standard book set for the default company. Each company gets its own set; see
|
||||
// seedBooksForCompany (also called when a new company is created).
|
||||
const defaultEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||
if (defaultEntity) seedBooksForCompany(defaultEntity.id);
|
||||
|
||||
const settings = {
|
||||
server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost',
|
||||
@@ -1091,5 +1268,7 @@ module.exports = {
|
||||
one,
|
||||
parseJson,
|
||||
run,
|
||||
seedBooksForCompany,
|
||||
seedReferenceValuesForCompany,
|
||||
tx
|
||||
};
|
||||
|
||||
13
server/middleware/company.js
Normal file
13
server/middleware/company.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Resolve the active company for the request from the X-Company-Id header. The company must be one
|
||||
// the user may access (see companies.companiesForUser); otherwise we fall back to their first
|
||||
// accessible company so the app always has a valid, permitted company context. Mount after requireAuth.
|
||||
// Required lazily to avoid a load-time cycle (companies -> roles -> ...).
|
||||
function resolveCompany(req, res, next) {
|
||||
const { companyIdsForUser } = require('../services/companies');
|
||||
const accessible = companyIdsForUser(req.user);
|
||||
const requested = Number(req.headers['x-company-id']);
|
||||
req.companyId = accessible.includes(requested) ? requested : (accessible[0] || null);
|
||||
return next();
|
||||
}
|
||||
|
||||
module.exports = { resolveCompany };
|
||||
@@ -17,8 +17,12 @@ const userSelect = `
|
||||
LEFT JOIN teams t ON t.id = u.team_id
|
||||
`;
|
||||
|
||||
const companies = require('../services/companies');
|
||||
|
||||
function publicAdminUser(id) {
|
||||
return one(`${userSelect} WHERE u.id = ?`, [id]);
|
||||
const user = one(`${userSelect} WHERE u.id = ?`, [id]);
|
||||
if (user) user.company_ids = companies.assignedCompanyIds(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
function validateRole(role) {
|
||||
@@ -45,7 +49,8 @@ router.put('/settings', requireCapability('admin.settings'), (req, res) => {
|
||||
});
|
||||
|
||||
router.get('/users', requireCapability('admin.users'), (req, res) => {
|
||||
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
|
||||
const users = all(`${userSelect} ORDER BY u.name`).map((u) => ({ ...u, company_ids: companies.assignedCompanyIds(u.id) }));
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
||||
@@ -77,6 +82,9 @@ router.post('/users', requireCapability('admin.users'), (req, res) => {
|
||||
]
|
||||
);
|
||||
passwordPolicy.recordPassword(result.lastInsertRowid, hash);
|
||||
// Assign company access: the supplied list, else default to the creating admin's active company.
|
||||
const companyIds = Array.isArray(req.body.company_ids) && req.body.company_ids.length ? req.body.company_ids : (req.companyId ? [req.companyId] : []);
|
||||
companies.setUserCompanies(result.lastInsertRowid, companyIds, req.user.id);
|
||||
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
|
||||
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
||||
});
|
||||
@@ -145,6 +153,10 @@ router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
||||
passwordPolicy.recordPassword(req.params.id, hash);
|
||||
}
|
||||
|
||||
if (req.body.company_ids !== undefined) {
|
||||
companies.setUserCompanies(req.params.id, req.body.company_ids, req.user.id);
|
||||
}
|
||||
|
||||
const user = publicAdminUser(req.params.id);
|
||||
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
|
||||
return res.json({ user });
|
||||
|
||||
@@ -8,12 +8,12 @@ const { submitTicket } = require('../services/servicenow');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/alerts', (req, res) => {
|
||||
res.json({ alerts: listAlerts(req.query), summary: summary() });
|
||||
res.json({ alerts: listAlerts({ ...req.query, entity_id: req.companyId }), summary: summary(req.companyId) });
|
||||
});
|
||||
|
||||
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() });
|
||||
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({ entity_id: req.companyId }), summary: summary(req.companyId) });
|
||||
});
|
||||
|
||||
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||
|
||||
@@ -21,48 +21,48 @@ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/assets', (req, res) => {
|
||||
res.json({ assets: listAssets(req.query) });
|
||||
res.json({ assets: listAssets({ ...req.query, entity_id: req.companyId }) });
|
||||
});
|
||||
|
||||
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
|
||||
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
|
||||
res.status(201).json({ asset: createAsset(req.body, req.user.id, req.companyId) });
|
||||
});
|
||||
|
||||
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);
|
||||
const asset = lookupAssetByCode(code, req.companyId);
|
||||
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' });
|
||||
if (!asset || (req.companyId && asset.entity_id !== req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.json({ asset });
|
||||
});
|
||||
|
||||
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => {
|
||||
const asset = updateAsset(req.params.id, req.body, req.user.id);
|
||||
const asset = updateAsset(req.params.id, req.body, req.user.id, req.companyId);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.json({ asset });
|
||||
});
|
||||
|
||||
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' });
|
||||
if (!deleteAsset(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
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);
|
||||
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id, req.companyId);
|
||||
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
|
||||
return res.json({ updated });
|
||||
});
|
||||
|
||||
router.get('/assets/:id/depreciation', (req, res) => {
|
||||
const asset = hydrateAsset(req.params.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
if (!asset || (req.companyId && asset.entity_id !== req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||
const startYear = Number(req.query.startYear || new Date().getFullYear());
|
||||
const endYear = Number(req.query.endYear || startYear + 5);
|
||||
const schedules = asset.books
|
||||
|
||||
@@ -21,25 +21,25 @@ router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
||||
|
||||
router.get('/assignments', (req, res) => {
|
||||
res.json({
|
||||
summary: assignmentSummary(),
|
||||
summary: assignmentSummary(req.companyId),
|
||||
assignments: assignmentRows({
|
||||
asset_id: req.query.asset_id || null,
|
||||
employee_id: req.query.employee_id || null,
|
||||
active: req.query.active === 'true'
|
||||
})
|
||||
}, req.companyId)
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
|
||||
res.status(201).json({ assignment: assignAsset(req.body, req.user.id, req.companyId) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => {
|
||||
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
|
||||
const assignment = releaseAssignment(req.params.id, req.body, req.user.id, req.companyId);
|
||||
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||
return res.json({ assignment });
|
||||
});
|
||||
|
||||
@@ -6,34 +6,34 @@ const { exportReport } = require('../services/reportExport');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/books', (req, res) => {
|
||||
res.json(listBooks());
|
||||
res.json(listBooks(req.companyId));
|
||||
});
|
||||
|
||||
router.post('/books', requireCapability('finance.manage'), (req, res) => {
|
||||
res.status(201).json({ book: createBook(req.body, req.user.id) });
|
||||
res.status(201).json({ book: createBook(req.body, req.user.id, req.companyId) });
|
||||
});
|
||||
|
||||
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||
const book = updateBook(req.params.id, req.body, req.user.id);
|
||||
const book = updateBook(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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' });
|
||||
if (!deleteBook(req.params.id, req.user.id, req.companyId)) 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);
|
||||
const ledger = bookLedger(req.params.code, year, req.companyId);
|
||||
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);
|
||||
const report = ledgerReport(req.params.code, year, req.companyId);
|
||||
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);
|
||||
|
||||
@@ -14,39 +14,39 @@ const router = express.Router();
|
||||
const editor = requireCapability('contacts.manage');
|
||||
|
||||
router.get('/contacts', (req, res) => {
|
||||
res.json({ contacts: listContacts(req.query) });
|
||||
res.json({ contacts: listContacts(req.query, req.companyId) });
|
||||
});
|
||||
|
||||
router.get('/contacts/:id', (req, res) => {
|
||||
const contact = getContact(req.params.id);
|
||||
const contact = getContact(req.params.id, req.companyId);
|
||||
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) });
|
||||
res.status(201).json({ contact: createContact(req.body, req.user.id, req.companyId) });
|
||||
});
|
||||
|
||||
router.put('/contacts/:id', editor, (req, res) => {
|
||||
const contact = updateContact(req.params.id, req.body, req.user.id);
|
||||
const contact = updateContact(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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' });
|
||||
if (!deleteContact(req.params.id, req.user.id, req.companyId)) 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);
|
||||
const note = addNote(req.params.id, req.body.body, req.user.id, req.companyId);
|
||||
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' });
|
||||
if (!deleteNote(req.params.id, req.params.noteId, req.user.id, req.companyId)) return res.status(404).json({ error: 'Note not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,24 +4,25 @@ const { all, one } = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/dashboard', (req, res) => {
|
||||
const cid = req.companyId || null;
|
||||
const stats = one(`
|
||||
SELECT
|
||||
COUNT(*) AS asset_count,
|
||||
COALESCE(SUM(acquisition_cost), 0) AS total_cost,
|
||||
COALESCE(SUM(CASE WHEN status = 'disposed' THEN 1 ELSE 0 END), 0) AS disposed_count,
|
||||
COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
|
||||
FROM assets
|
||||
`);
|
||||
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets GROUP BY category ORDER BY value DESC');
|
||||
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets GROUP BY status ORDER BY count DESC');
|
||||
FROM assets WHERE (? IS NULL OR entity_id = ?)
|
||||
`, [cid, cid]);
|
||||
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets WHERE (? IS NULL OR entity_id = ?) GROUP BY category ORDER BY value DESC', [cid, cid]);
|
||||
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets WHERE (? IS NULL OR entity_id = ?) GROUP BY status ORDER BY count DESC', [cid, cid]);
|
||||
const upcomingTasks = all(`
|
||||
SELECT t.*, a.asset_id, a.description
|
||||
FROM tasks t
|
||||
LEFT JOIN assets a ON a.id = t.asset_id
|
||||
WHERE t.status != 'complete'
|
||||
WHERE t.status != 'complete' AND (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY t.due_date IS NULL, t.due_date
|
||||
LIMIT 8
|
||||
`);
|
||||
`, [cid, cid]);
|
||||
const auditTrail = all('SELECT al.*, u.name AS actor_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id ORDER BY al.id DESC LIMIT 10');
|
||||
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const router = express.Router();
|
||||
|
||||
router.get('/export/assets', async (req, res) => {
|
||||
const format = String(req.query.format || 'json').toLowerCase();
|
||||
const output = await assetExport(format);
|
||||
const output = await assetExport(format, req.companyId);
|
||||
res.setHeader('Content-Type', output.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
|
||||
return res.send(output.body);
|
||||
@@ -19,7 +19,7 @@ router.get('/export/assets', 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);
|
||||
const imported = upsertImportedAssets(rows, req.user.id, req.companyId);
|
||||
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
|
||||
res.json({ imported });
|
||||
});
|
||||
|
||||
@@ -17,59 +17,59 @@ const router = express.Router();
|
||||
const editor = requireCapability('parts.manage');
|
||||
|
||||
router.get('/parts', (req, res) => {
|
||||
res.json({ parts: listParts(req.query) });
|
||||
res.json({ parts: listParts(req.query, req.companyId) });
|
||||
});
|
||||
|
||||
router.get('/parts/:id', (req, res) => {
|
||||
const part = getPart(req.params.id);
|
||||
const part = getPart(req.params.id, req.companyId);
|
||||
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) });
|
||||
res.status(201).json({ part: createPart(req.body, req.user.id, req.companyId) });
|
||||
});
|
||||
|
||||
router.put('/parts/:id', editor, (req, res) => {
|
||||
const part = updatePart(req.params.id, req.body, req.user.id);
|
||||
const part = updatePart(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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' });
|
||||
if (!deletePart(req.params.id, req.user.id, req.companyId)) 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);
|
||||
const part = saveStock(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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);
|
||||
const part = deleteStock(req.params.id, req.params.stockId, req.user.id, req.companyId);
|
||||
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);
|
||||
const part = recordTransaction(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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);
|
||||
const part = addPhoto(req.params.id, req.body, req.user.id, req.companyId);
|
||||
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);
|
||||
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id, req.companyId);
|
||||
if (!part) return res.status(404).json({ error: 'Photo not found' });
|
||||
return res.json({ part });
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const assetPm = requireCapability('pm.complete'); // attach to assets & complete
|
||||
|
||||
// PM plan templates
|
||||
router.get('/pm-plans', (req, res) => {
|
||||
res.json({ plans: listPlans() });
|
||||
res.json({ plans: listPlans(req.companyId) });
|
||||
});
|
||||
|
||||
router.get('/pm-plans/:id', (req, res) => {
|
||||
@@ -35,7 +35,7 @@ router.get('/pm-plans/:id', (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/pm-plans', planEditor, (req, res) => {
|
||||
res.status(201).json({ plan: createPlan(req.body, req.user.id) });
|
||||
res.status(201).json({ plan: createPlan(req.body, req.user.id, req.companyId) });
|
||||
});
|
||||
|
||||
router.put('/pm-plans/:id', planEditor, (req, res) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 companies = require('../services/companies');
|
||||
const assetClasses = require('../services/assetClasses');
|
||||
const zones = require('../services/zones');
|
||||
const section179Limits = require('../services/section179Limits');
|
||||
@@ -95,30 +96,43 @@ router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, re
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// Companies (stored in `entities`). Returns the user's accessible active companies for the switcher;
|
||||
// company admins requesting ?includeDisposed=1 get the full management list (incl. disposed).
|
||||
router.get('/entities', (req, res) => {
|
||||
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
|
||||
if (req.query.includeDisposed === '1' && companies.isCompanyAdmin(req.user)) {
|
||||
return res.json({ entities: companies.listCompanies({ includeDisposed: true }) });
|
||||
}
|
||||
return res.json({ entities: companies.companiesForUser(req.user) });
|
||||
});
|
||||
|
||||
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.post('/entities', requireCapability('config.manage'), (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ entity: companies.createCompany(req.body, req.user.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/entities/:id', requireCapability('config.manage'), (req, res) => {
|
||||
const entity = companies.updateCompany(req.params.id, req.body, req.user.id);
|
||||
if (!entity) return res.status(404).json({ error: 'Company not found' });
|
||||
return res.json({ entity });
|
||||
});
|
||||
|
||||
router.post('/entities/:id/dispose', requireCapability('config.manage'), (req, res) => {
|
||||
const result = companies.disposeCompany(req.params.id, req.body || {}, req.user.id);
|
||||
if (!result.ok) return res.status(result.status).json({ error: result.error });
|
||||
return res.json({ entity: result.company });
|
||||
});
|
||||
|
||||
router.post('/entities/:id/restore', requireCapability('config.manage'), (req, res) => {
|
||||
const entity = companies.restoreCompany(req.params.id, req.user.id);
|
||||
if (!entity) return res.status(404).json({ error: 'Company not found' });
|
||||
return res.json({ entity });
|
||||
});
|
||||
|
||||
router.get('/references', (req, res) => {
|
||||
const rows = all('SELECT * FROM reference_values ORDER BY type, name');
|
||||
const rows = all('SELECT * FROM reference_values WHERE entity_id = ? ORDER BY type, name', [req.companyId]);
|
||||
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
||||
});
|
||||
|
||||
@@ -144,7 +158,7 @@ function categoryRow(row) {
|
||||
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
|
||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,31 +179,31 @@ function resolveCategoryClass(body, prev = {}) {
|
||||
const categoryEditor = requireCapability('config.manage');
|
||||
|
||||
router.get('/asset-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name");
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||
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])) {
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
return res.status(400).json({ error: 'That category already exists' });
|
||||
}
|
||||
const { classId, classNumber } = resolveCategoryClass(req.body);
|
||||
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_id: classId, asset_class_number: classNumber })]
|
||||
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'category', ?, ?, ?)",
|
||||
[req.companyId, name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_id: classId, asset_class_number: classNumber })]
|
||||
);
|
||||
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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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])) {
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
return res.status(400).json({ error: 'That category already exists' });
|
||||
}
|
||||
const meta = parseJson(before.metadata, {});
|
||||
@@ -203,20 +217,20 @@ router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||
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;
|
||||
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).changes || 0;
|
||||
}
|
||||
// The class number is shared by every asset in the category — cascade any change.
|
||||
// The class number is shared by every asset in the category — cascade any change (within this company).
|
||||
if (classChanged) {
|
||||
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
||||
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [meta.asset_class_number, now(), name, req.companyId]);
|
||||
}
|
||||
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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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;
|
||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ? AND entity_id = ?', [before.name, req.companyId]).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 });
|
||||
@@ -230,50 +244,50 @@ function departmentRow(row) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c
|
||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/asset-departments', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name");
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'department' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||
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])) {
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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]
|
||||
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'department', ?, ?, '{}')",
|
||||
[req.companyId, 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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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])) {
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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;
|
||||
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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;
|
||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ? AND entity_id = ?', [before.name, req.companyId]).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 });
|
||||
@@ -287,50 +301,50 @@ function pmCategoryRow(row) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c
|
||||
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/pm-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name");
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||
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])) {
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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]
|
||||
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'pm_category', ?, ?, '{}')",
|
||||
[req.companyId, 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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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])) {
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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;
|
||||
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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;
|
||||
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ? AND entity_id = ?', [before.name, req.companyId]).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 });
|
||||
@@ -349,30 +363,30 @@ function partCategoryRow(row) {
|
||||
}
|
||||
|
||||
router.get('/part-categories', (req, res) => {
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name");
|
||||
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||
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])) {
|
||||
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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]
|
||||
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'part_category', ?, ?, '{}')",
|
||||
[req.companyId, 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]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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])) {
|
||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||
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]);
|
||||
@@ -385,7 +399,7 @@ router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
||||
});
|
||||
|
||||
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||
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]);
|
||||
|
||||
@@ -18,12 +18,12 @@ router.get('/reports/catalog', (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/reports/run', (req, res) => {
|
||||
res.json({ report: runReport(req.body.type, req.body.options || {}) });
|
||||
res.json({ report: runReport(req.body.type, { ...(req.body.options || {}), entity_id: req.companyId }) });
|
||||
});
|
||||
|
||||
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 report = runReport(req.body.type, { ...(req.body.options || {}), entity_id: req.companyId });
|
||||
const output = await exportReport(report, format);
|
||||
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
||||
res.setHeader('Content-Type', output.contentType);
|
||||
@@ -34,15 +34,16 @@ router.post('/reports/export', async (req, res) => {
|
||||
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`
|
||||
LEFT JOIN users u ON u.id = s.created_by WHERE s.entity_id = ? ORDER BY s.name`,
|
||||
[req.companyId]
|
||||
).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()]
|
||||
'INSERT INTO saved_reports (entity_id, name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[req.companyId, 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]);
|
||||
@@ -50,7 +51,7 @@ router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
||||
});
|
||||
|
||||
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
|
||||
const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]);
|
||||
const result = run('DELETE FROM saved_reports WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||
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();
|
||||
@@ -59,19 +60,19 @@ router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res
|
||||
router.get('/reports/depreciation', (req, res) => {
|
||||
const year = Number(req.query.year || new Date().getFullYear());
|
||||
const bookType = req.query.book || 'GAAP';
|
||||
res.json(depreciationReport(year, bookType));
|
||||
res.json(depreciationReport(year, bookType, req.companyId));
|
||||
});
|
||||
|
||||
router.get('/reports/monthly-depreciation', (req, res) => {
|
||||
const year = Number(req.query.year || new Date().getFullYear());
|
||||
const bookType = req.query.book || 'GAAP';
|
||||
res.json(monthlyDepreciationReport(year, bookType));
|
||||
res.json(monthlyDepreciationReport(year, bookType, req.companyId));
|
||||
});
|
||||
|
||||
router.get('/reports/depreciation.pdf', (req, res) => {
|
||||
const year = Number(req.query.year || new Date().getFullYear());
|
||||
const book = req.query.book || 'GAAP';
|
||||
writeDepreciationPdf(res, year, book);
|
||||
writeDepreciationPdf(res, year, book, req.companyId);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,7 +7,8 @@ const router = express.Router();
|
||||
router.get('/templates', (req, res) => {
|
||||
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`
|
||||
FROM asset_templates t WHERE t.entity_id = ? ORDER BY t.name`,
|
||||
[req.companyId]
|
||||
).map((row) => ({
|
||||
...row,
|
||||
defaults: parseJson(row.defaults, {}),
|
||||
@@ -19,15 +20,15 @@ router.get('/templates', (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 (?, ?, ?, ?, ?, ?)',
|
||||
[req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
|
||||
'INSERT INTO asset_templates (entity_id, name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.companyId, req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
|
||||
);
|
||||
audit(req.user.id, 'create', 'asset_template', result.lastInsertRowid, null, req.body);
|
||||
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
||||
});
|
||||
|
||||
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||
const before = one('SELECT * FROM asset_templates WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||
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 = ?',
|
||||
@@ -47,7 +48,7 @@ router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||
// 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]);
|
||||
const before = one('SELECT * FROM asset_templates WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||
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]);
|
||||
|
||||
@@ -78,20 +78,22 @@ function scanAlerts(userId) {
|
||||
|
||||
for (const c of cands) {
|
||||
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
|
||||
// Every candidate derives from an asset; the alert inherits that asset's company for filtering.
|
||||
const entityId = c.asset_id ? (one('SELECT entity_id FROM assets WHERE id = ?', [c.asset_id])?.entity_id ?? null) : null;
|
||||
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
|
||||
if (existing) {
|
||||
if (existing.status === 'dismissed') continue;
|
||||
run(
|
||||
`UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
||||
`UPDATE alerts SET asset_id = ?, entity_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
||||
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id]
|
||||
[c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, existing.id]
|
||||
);
|
||||
} else {
|
||||
const result = run(
|
||||
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts]
|
||||
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, entity_id, severity, title, message, due_date, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||
[c.type, c.reference_type, c.reference_id, c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, ts]
|
||||
);
|
||||
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
|
||||
}
|
||||
@@ -113,14 +115,17 @@ function listAlerts(query = {}) {
|
||||
FROM alerts al
|
||||
LEFT JOIN assets a ON a.id = al.asset_id
|
||||
LEFT JOIN users u ON u.id = al.acknowledged_by
|
||||
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?)
|
||||
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?) AND (? IS NULL OR al.entity_id = ?)
|
||||
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
|
||||
[query.status || null, query.status || null, query.type || null, query.type || null]
|
||||
[query.status || null, query.status || null, query.type || null, query.type || null, query.entity_id || null, query.entity_id || null]
|
||||
);
|
||||
}
|
||||
|
||||
function summary() {
|
||||
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
|
||||
function summary(companyId) {
|
||||
const rows = all(
|
||||
"SELECT status, severity, COUNT(*) AS count FROM alerts WHERE (? IS NULL OR entity_id = ?) GROUP BY status, severity",
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
const open = rows.filter((r) => r.status === 'open');
|
||||
return {
|
||||
open: open.reduce((s, r) => s + r.count, 0),
|
||||
|
||||
@@ -44,9 +44,12 @@ const assetColumns = [
|
||||
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||
|
||||
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
|
||||
function enabledBookCodes() {
|
||||
function enabledBookCodes(companyId) {
|
||||
try {
|
||||
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
|
||||
const rows = all(
|
||||
"SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' AND (? IS NULL OR entity_id = ?) ORDER BY sort_order, id",
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
if (rows.length) return rows.map((row) => row.code);
|
||||
} catch {
|
||||
// books table may not exist on a very old database
|
||||
@@ -121,17 +124,18 @@ function hydrateAsset(id) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function lookupAssetByCode(code) {
|
||||
function lookupAssetByCode(code, companyId) {
|
||||
const value = String(code || '').trim();
|
||||
if (!value) return null;
|
||||
const row = one(
|
||||
`SELECT id FROM assets
|
||||
WHERE barcode_value = ? COLLATE NOCASE
|
||||
WHERE (? IS NULL OR entity_id = ?)
|
||||
AND (barcode_value = ? COLLATE NOCASE
|
||||
OR asset_id = ? COLLATE NOCASE
|
||||
OR serial_number = ? COLLATE NOCASE
|
||||
OR serial_number = ? COLLATE NOCASE)
|
||||
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
|
||||
LIMIT 1`,
|
||||
[value, value, value, value]
|
||||
[companyId || null, companyId || null, value, value, value, value]
|
||||
);
|
||||
return row ? hydrateAsset(row.id) : null;
|
||||
}
|
||||
@@ -212,8 +216,8 @@ function normalizeAssetInput(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function createBooks(assetId, asset, books = []) {
|
||||
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
|
||||
function createBooks(assetId, asset, books = [], companyId) {
|
||||
const selectedBooks = books.length ? books : enabledBookCodes(companyId).map((book) => ({ book_type: book }));
|
||||
for (const book of selectedBooks) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO asset_books (
|
||||
@@ -262,9 +266,10 @@ function listAssets(query = {}) {
|
||||
]).map(assetFromRow);
|
||||
}
|
||||
|
||||
function createAsset(body, userId) {
|
||||
function createAsset(body, userId, companyId) {
|
||||
const created = now();
|
||||
const input = normalizeAssetInput(body);
|
||||
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
||||
const result = tx(() => {
|
||||
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
||||
const insert = run(
|
||||
@@ -272,7 +277,7 @@ function createAsset(body, userId) {
|
||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
||||
);
|
||||
createBooks(insert.lastInsertRowid, input, body.books || []);
|
||||
createBooks(insert.lastInsertRowid, input, body.books || [], input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
|
||||
return insert;
|
||||
});
|
||||
@@ -281,11 +286,13 @@ function createAsset(body, userId) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function updateAsset(id, body, userId) {
|
||||
function updateAsset(id, body, userId, companyId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return null;
|
||||
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
|
||||
|
||||
const input = normalizeAssetInput({ ...before, ...body });
|
||||
if (companyId) input.entity_id = companyId; // assets stay within their company in phase 1
|
||||
// When depreciation-critical fields change, the caller chooses when the change applies.
|
||||
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
|
||||
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
|
||||
@@ -296,7 +303,7 @@ function updateAsset(id, body, userId) {
|
||||
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
||||
);
|
||||
if (Array.isArray(body.books)) createBooks(id, input, body.books);
|
||||
if (Array.isArray(body.books)) createBooks(id, input, body.books, input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
|
||||
});
|
||||
|
||||
@@ -305,17 +312,23 @@ function updateAsset(id, body, userId) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
function deleteAsset(id, userId) {
|
||||
function deleteAsset(id, userId, companyId) {
|
||||
const before = hydrateAsset(id);
|
||||
if (!before) return false;
|
||||
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
|
||||
run('DELETE FROM assets WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'asset', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function massUpdateAssets(ids, changes, userId) {
|
||||
function massUpdateAssets(ids, changes, userId, companyId) {
|
||||
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
||||
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
||||
// Restrict to assets in the active company so a forged id list can't touch another company's data.
|
||||
if (companyId && ids.length) {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
ids = all(`SELECT id FROM assets WHERE entity_id = ? AND id IN (${placeholders})`, [companyId, ...ids]).map((r) => r.id);
|
||||
}
|
||||
if (!ids.length || !columns.length) return 0;
|
||||
|
||||
tx(() => {
|
||||
@@ -330,26 +343,29 @@ function massUpdateAssets(ids, changes, userId) {
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
function assetExportRows() {
|
||||
function assetExportRows(companyId) {
|
||||
return all(`
|
||||
SELECT a.*, e.name AS entity_name
|
||||
FROM assets a
|
||||
LEFT JOIN entities e ON e.id = a.entity_id
|
||||
WHERE (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY a.asset_id
|
||||
`).map((asset) => ({
|
||||
`, [companyId || null, companyId || null]).map((asset) => ({
|
||||
...asset,
|
||||
listed_property: Boolean(asset.listed_property),
|
||||
custom_fields: parseJson(asset.custom_fields, {})
|
||||
}));
|
||||
}
|
||||
|
||||
function upsertImportedAssets(rows, userId) {
|
||||
function upsertImportedAssets(rows, userId, companyId) {
|
||||
let imported = 0;
|
||||
tx(() => {
|
||||
for (const row of rows) {
|
||||
const input = normalizeAssetInput(row);
|
||||
if (companyId) input.entity_id = companyId; // imports land in the active company
|
||||
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
|
||||
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
||||
// Match within the active company so imports don't collide with another company's asset ids.
|
||||
const existing = one('SELECT id FROM assets WHERE asset_id = ? AND (? IS NULL OR entity_id = ?)', [input.asset_id, companyId || null, companyId || null]);
|
||||
const classNumber = assetClassNumberForCategory(input.category);
|
||||
if (existing) {
|
||||
run(
|
||||
@@ -362,7 +378,7 @@ function upsertImportedAssets(rows, userId) {
|
||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
||||
);
|
||||
createBooks(result.lastInsertRowid, input, []);
|
||||
createBooks(result.lastInsertRowid, input, [], input.entity_id);
|
||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
|
||||
}
|
||||
imported += 1;
|
||||
|
||||
@@ -87,19 +87,21 @@ function upsertEmployee(employee) {
|
||||
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
||||
}
|
||||
|
||||
function assignmentRows(query = {}) {
|
||||
function assignmentRows(query = {}, companyId) {
|
||||
return all(`
|
||||
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description,
|
||||
e.name AS employee_name, e.email AS employee_email, e.employee_number
|
||||
FROM asset_assignments aa
|
||||
JOIN assets a ON a.id = aa.asset_id
|
||||
LEFT JOIN employees e ON e.id = aa.employee_id
|
||||
WHERE (? IS NULL OR aa.asset_id = ?)
|
||||
WHERE (? IS NULL OR a.entity_id = ?)
|
||||
AND (? IS NULL OR aa.asset_id = ?)
|
||||
AND (? IS NULL OR aa.employee_id = ?)
|
||||
AND (? IS NULL OR aa.released_at IS NULL)
|
||||
ORDER BY aa.assigned_at DESC, aa.id DESC
|
||||
LIMIT 500
|
||||
`, [
|
||||
companyId || null, companyId || null,
|
||||
query.asset_id || null,
|
||||
query.asset_id || null,
|
||||
query.employee_id || null,
|
||||
@@ -108,9 +110,10 @@ function assignmentRows(query = {}) {
|
||||
]).map(assignmentFromRow);
|
||||
}
|
||||
|
||||
function assignAsset(body, userId) {
|
||||
function assignAsset(body, userId, companyId) {
|
||||
const timestamp = now();
|
||||
const asset = one('SELECT * FROM assets WHERE id = ?', [body.asset_id]);
|
||||
// The asset must belong to the active company so assignments can't cross companies.
|
||||
const asset = one('SELECT * FROM assets WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.asset_id, companyId || null, companyId || null]);
|
||||
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
||||
|
||||
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
|
||||
@@ -154,15 +157,18 @@ function assignAsset(body, userId) {
|
||||
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
|
||||
);
|
||||
|
||||
const assignment = assignmentRows({ asset_id: body.asset_id, active: true })[0];
|
||||
const assignment = assignmentRows({ asset_id: body.asset_id, active: true }, companyId)[0];
|
||||
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
|
||||
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAssignment(id, body, userId) {
|
||||
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
function releaseAssignment(id, body, userId, companyId) {
|
||||
const before = one(
|
||||
'SELECT aa.*, a.entity_id AS asset_entity_id FROM asset_assignments aa JOIN assets a ON a.id = aa.asset_id WHERE aa.id = ?',
|
||||
[id]
|
||||
);
|
||||
if (!before || (companyId && before.asset_entity_id !== companyId)) return null;
|
||||
const timestamp = now();
|
||||
run(
|
||||
`UPDATE asset_assignments
|
||||
@@ -171,26 +177,28 @@ function releaseAssignment(id, body, userId) {
|
||||
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
|
||||
);
|
||||
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body });
|
||||
return assignmentRows({ asset_id: before.asset_id }).find((assignment) => assignment.id === Number(id));
|
||||
return assignmentRows({ asset_id: before.asset_id }, companyId).find((assignment) => assignment.id === Number(id));
|
||||
}
|
||||
|
||||
function assignmentSummary() {
|
||||
function assignmentSummary(companyId) {
|
||||
const stats = one(`
|
||||
SELECT
|
||||
COUNT(*) AS active_assignments,
|
||||
COUNT(DISTINCT employee_id) AS assigned_employees,
|
||||
COUNT(DISTINCT location) AS assigned_locations
|
||||
FROM asset_assignments
|
||||
WHERE released_at IS NULL
|
||||
`);
|
||||
COUNT(DISTINCT aa.employee_id) AS assigned_employees,
|
||||
COUNT(DISTINCT aa.location) AS assigned_locations
|
||||
FROM asset_assignments aa
|
||||
JOIN assets a ON a.id = aa.asset_id
|
||||
WHERE aa.released_at IS NULL AND (? IS NULL OR a.entity_id = ?)
|
||||
`, [companyId || null, companyId || null]);
|
||||
const unassigned = one(`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM assets a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM asset_assignments aa
|
||||
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
||||
)
|
||||
`);
|
||||
WHERE (? IS NULL OR a.entity_id = ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM asset_assignments aa
|
||||
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
||||
)
|
||||
`, [companyId || null, companyId || null]);
|
||||
return { ...stats, unassigned_assets: unassigned.count };
|
||||
}
|
||||
|
||||
|
||||
@@ -19,35 +19,39 @@ function bookFromRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function listBooks() {
|
||||
function listBooks(companyId) {
|
||||
const books = all(
|
||||
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
|
||||
(SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count
|
||||
(SELECT COUNT(*) FROM asset_books ab JOIN assets a ON a.id = ab.asset_id
|
||||
WHERE ab.book_type = b.code AND ab.active = 1 AND a.entity_id = b.entity_id) AS asset_count
|
||||
FROM books b
|
||||
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
|
||||
ORDER BY b.sort_order, b.id`
|
||||
WHERE (? IS NULL OR b.entity_id = ?)
|
||||
ORDER BY b.sort_order, b.id`,
|
||||
[companyId || null, companyId || null]
|
||||
).map(bookFromRow);
|
||||
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
|
||||
.map((row) => ({ ...row, active: Boolean(row.active) }));
|
||||
return { books, ruleSets };
|
||||
}
|
||||
|
||||
function getBook(code) {
|
||||
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null;
|
||||
function getBook(code, companyId) {
|
||||
return bookFromRow(one('SELECT * FROM books WHERE (code = ? OR id = ?) AND (? IS NULL OR entity_id = ?)', [code, code, companyId || null, companyId || null]) || null) || null;
|
||||
}
|
||||
|
||||
function createBook(body, userId) {
|
||||
function createBook(body, userId, companyId) {
|
||||
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
|
||||
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
|
||||
if (one('SELECT id FROM books WHERE code = ?', [code])) {
|
||||
if (one('SELECT id FROM books WHERE code = ? AND entity_id = ?', [code, companyId])) {
|
||||
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
|
||||
}
|
||||
const ts = now();
|
||||
const next = one('SELECT MAX(sort_order) AS max FROM books');
|
||||
const next = one('SELECT MAX(sort_order) AS max FROM books WHERE entity_id = ?', [companyId]);
|
||||
const result = run(
|
||||
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO books (entity_id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
companyId,
|
||||
code,
|
||||
body.name || code,
|
||||
body.description || null,
|
||||
@@ -65,20 +69,20 @@ function createBook(body, userId) {
|
||||
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
||||
}
|
||||
|
||||
function deleteBook(id, userId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
||||
function deleteBook(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||
if (!before) return false;
|
||||
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
|
||||
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
|
||||
const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count;
|
||||
const used = one('SELECT COUNT(*) AS count FROM asset_books ab JOIN assets a ON a.id = ab.asset_id WHERE ab.book_type = ? AND a.entity_id = ?', [before.code, before.entity_id]).count;
|
||||
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
|
||||
run('DELETE FROM books WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'book', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateBook(id, body, userId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
||||
function updateBook(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||
if (!before) return null;
|
||||
run(
|
||||
`UPDATE books SET
|
||||
@@ -104,11 +108,11 @@ function updateBook(id, body, userId) {
|
||||
|
||||
// General-ledger view of a book: per-asset basis, accumulated depreciation and
|
||||
// current-year expense rolled up to GL accounts, plus an asset-level detail.
|
||||
function bookLedger(code, year) {
|
||||
const book = getBook(code);
|
||||
function bookLedger(code, year, companyId) {
|
||||
const book = getBook(code, companyId);
|
||||
if (!book) return null;
|
||||
if (book.book_type === 'maintenance') {
|
||||
return { ...pmBookLedger(year), book };
|
||||
return { ...pmBookLedger(year, companyId), book };
|
||||
}
|
||||
const rules = ruleSetForBook(code);
|
||||
const rows = all(
|
||||
@@ -116,9 +120,9 @@ function bookLedger(code, year) {
|
||||
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
|
||||
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
||||
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||
WHERE b.book_type = ? AND b.active = 1
|
||||
WHERE b.book_type = ? AND b.active = 1 AND (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY a.asset_id`,
|
||||
[code]
|
||||
[code, companyId || null, companyId || null]
|
||||
);
|
||||
|
||||
const accountMap = new Map();
|
||||
@@ -195,8 +199,8 @@ function bookLedger(code, year) {
|
||||
}
|
||||
|
||||
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
||||
function ledgerReport(code, year) {
|
||||
const ledger = bookLedger(code, year);
|
||||
function ledgerReport(code, year, companyId) {
|
||||
const ledger = bookLedger(code, year, companyId);
|
||||
if (!ledger) return null;
|
||||
return {
|
||||
title: `${ledger.book.name} general ledger — ${year}`,
|
||||
|
||||
128
server/services/companies.js
Normal file
128
server/services/companies.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db');
|
||||
|
||||
// Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and
|
||||
// (in phase 2) reference data. Disposing a company is a soft status change that preserves all history.
|
||||
|
||||
function listCompanies({ includeDisposed = false } = {}) {
|
||||
const where = includeDisposed ? '' : "WHERE status = 'active'";
|
||||
return all(`SELECT * FROM entities ${where} ORDER BY status, name`);
|
||||
}
|
||||
|
||||
function getCompany(id) {
|
||||
return one('SELECT * FROM entities WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
function activeCount() {
|
||||
return one("SELECT COUNT(*) AS c FROM entities WHERE status = 'active'").c;
|
||||
}
|
||||
|
||||
function createCompany(body, userId) {
|
||||
const name = String(body.name || '').trim();
|
||||
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
|
||||
const ts = now();
|
||||
const result = run(
|
||||
`INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||
[name, body.legal_name || null, body.tax_id || null, Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts]
|
||||
);
|
||||
// A new company starts with the standard book set + reference data (categories/locations/depts).
|
||||
seedBooksForCompany(result.lastInsertRowid);
|
||||
seedReferenceValuesForCompany(result.lastInsertRowid);
|
||||
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
|
||||
return getCompany(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function updateCompany(id, body, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return null;
|
||||
run(
|
||||
`UPDATE entities SET name = ?, legal_name = ?, tax_id = ?, fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
|
||||
body.legal_name !== undefined ? body.legal_name : before.legal_name,
|
||||
body.tax_id !== undefined ? body.tax_id : before.tax_id,
|
||||
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
|
||||
body.base_currency !== undefined ? body.base_currency : before.base_currency,
|
||||
now(), id
|
||||
]
|
||||
);
|
||||
audit(userId, 'update', 'entity', id, before, body);
|
||||
return getCompany(id);
|
||||
}
|
||||
|
||||
// Archive/retire a company: it stays in the database (assets, books, GL preserved) but is hidden from
|
||||
// active selection and can no longer be the active company. Guards the last active company.
|
||||
function disposeCompany(id, body, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return { ok: false, status: 404, error: 'Company not found' };
|
||||
if (before.status === 'disposed') return { ok: false, status: 400, error: 'Company is already disposed' };
|
||||
if (activeCount() <= 1) return { ok: false, status: 400, error: 'At least one active company is required' };
|
||||
run("UPDATE entities SET status = 'disposed', disposed_at = ?, disposed_reason = ?, updated_at = ? WHERE id = ?",
|
||||
[now(), body.reason || null, now(), id]);
|
||||
audit(userId, 'dispose', 'entity', id, before, { reason: body.reason || null });
|
||||
return { ok: true, company: getCompany(id) };
|
||||
}
|
||||
|
||||
function restoreCompany(id, userId) {
|
||||
const before = getCompany(id);
|
||||
if (!before) return null;
|
||||
run("UPDATE entities SET status = 'active', disposed_at = NULL, disposed_reason = NULL, updated_at = ? WHERE id = ?", [now(), id]);
|
||||
audit(userId, 'restore', 'entity', id, before, null);
|
||||
return getCompany(id);
|
||||
}
|
||||
|
||||
// ---- Per-company user access ------------------------------------------------
|
||||
|
||||
// A user who can manage users (admin.users / wildcard) sees every company; this prevents an admin
|
||||
// from locking themselves out of any company. Required lazily to avoid a load-time cycle.
|
||||
function isCompanyAdmin(user) {
|
||||
const { capabilitiesForRole } = require('./roles');
|
||||
const { capabilitySetIncludes } = require('./accessControl');
|
||||
return capabilitySetIncludes(capabilitiesForRole(user.role), 'admin.users');
|
||||
}
|
||||
|
||||
// Active companies a user may see/switch to. Admins → all; others → their assigned companies, falling
|
||||
// back to the default company when they have none (so a user is never locked out).
|
||||
function companiesForUser(user) {
|
||||
if (isCompanyAdmin(user)) {
|
||||
return all("SELECT * FROM entities WHERE status = 'active' ORDER BY name");
|
||||
}
|
||||
const rows = all(
|
||||
`SELECT e.* FROM entities e JOIN user_companies uc ON uc.entity_id = e.id
|
||||
WHERE uc.user_id = ? AND e.status = 'active' ORDER BY e.name`,
|
||||
[user.id]
|
||||
);
|
||||
if (rows.length) return rows;
|
||||
const def = one("SELECT * FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||
return def ? [def] : [];
|
||||
}
|
||||
|
||||
function companyIdsForUser(user) {
|
||||
return companiesForUser(user).map((c) => c.id);
|
||||
}
|
||||
|
||||
// Explicit assignments stored for a user (independent of the admin bypass).
|
||||
function assignedCompanyIds(userId) {
|
||||
return all('SELECT entity_id FROM user_companies WHERE user_id = ?', [userId]).map((r) => r.entity_id);
|
||||
}
|
||||
|
||||
function setUserCompanies(userId, ids, actorId) {
|
||||
const valid = (Array.isArray(ids) ? ids : []).filter((id) => one('SELECT id FROM entities WHERE id = ?', [id]));
|
||||
run('DELETE FROM user_companies WHERE user_id = ?', [userId]);
|
||||
for (const id of valid) run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) VALUES (?, ?)', [userId, id]);
|
||||
if (actorId) audit(actorId, 'set_companies', 'user', userId, null, { company_ids: valid });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assignedCompanyIds,
|
||||
companiesForUser,
|
||||
companyIdsForUser,
|
||||
createCompany,
|
||||
disposeCompany,
|
||||
getCompany,
|
||||
isCompanyAdmin,
|
||||
listCompanies,
|
||||
restoreCompany,
|
||||
setUserCompanies,
|
||||
updateCompany
|
||||
};
|
||||
@@ -47,10 +47,11 @@ function normalize(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function listContacts(query = {}) {
|
||||
function listContacts(query = {}, companyId) {
|
||||
return all(
|
||||
`SELECT * FROM contacts
|
||||
WHERE (? IS NULL OR type = ?)
|
||||
WHERE (? IS NULL OR entity_id = ?)
|
||||
AND (? IS NULL OR type = ?)
|
||||
AND (? IS NULL OR status = ?)
|
||||
AND (? IS NULL OR (
|
||||
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
|
||||
@@ -59,6 +60,7 @@ function listContacts(query = {}) {
|
||||
))
|
||||
ORDER BY type, organization, last_name, first_name`,
|
||||
[
|
||||
companyId || null, companyId || null,
|
||||
query.type || null, query.type || null,
|
||||
query.status || null, query.status || null,
|
||||
query.search || null, query.search || null, query.search || null,
|
||||
@@ -67,51 +69,52 @@ function listContacts(query = {}) {
|
||||
).map((row) => fromRow(row));
|
||||
}
|
||||
|
||||
function getContact(id) {
|
||||
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
|
||||
function getContact(id, companyId) {
|
||||
return fromRow(one('SELECT * FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]), true);
|
||||
}
|
||||
|
||||
function createContact(body, userId) {
|
||||
function createContact(body, userId, companyId) {
|
||||
const input = normalize(body);
|
||||
const ts = now();
|
||||
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||
const result = run(
|
||||
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...cols.map((c) => input[c]), userId, ts, ts]
|
||||
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
|
||||
);
|
||||
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
|
||||
return getContact(result.lastInsertRowid);
|
||||
return getContact(result.lastInsertRowid, companyId);
|
||||
}
|
||||
|
||||
function updateContact(id, body, userId) {
|
||||
function updateContact(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) return null;
|
||||
const input = normalize({ ...before, ...body });
|
||||
run(
|
||||
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||
);
|
||||
audit(userId, 'update', 'contact', id, before, body);
|
||||
return getContact(id);
|
||||
return getContact(id, companyId);
|
||||
}
|
||||
|
||||
function deleteContact(id, userId) {
|
||||
function deleteContact(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||
if (!before) return false;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) return false;
|
||||
run('DELETE FROM contacts WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'contact', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function addNote(contactId, bodyText, userId) {
|
||||
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null;
|
||||
function addNote(contactId, bodyText, userId, companyId) {
|
||||
if (!one('SELECT id FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [contactId, companyId || null, companyId || null])) return null;
|
||||
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
|
||||
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
|
||||
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
|
||||
}
|
||||
|
||||
function deleteNote(contactId, noteId, userId) {
|
||||
function deleteNote(contactId, noteId, userId, companyId) {
|
||||
if (companyId && !one('SELECT id FROM contacts WHERE id = ? AND entity_id = ?', [contactId, companyId])) return false;
|
||||
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
|
||||
if (!result.changes) return false;
|
||||
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
|
||||
@@ -152,10 +155,12 @@ function upsertWorkdayContact(worker) {
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
// Workday sync is global (no request company context); new workers land in the default company.
|
||||
const defaultCompany = one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||
const cols = Object.keys(fields);
|
||||
const result = run(
|
||||
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...cols.map((c) => fields[c]), ts, ts, ts]
|
||||
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[defaultCompany ? defaultCompany.id : null, ...cols.map((c) => fields[c]), ts, ts, ts]
|
||||
);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ async function rowsFromImport(format, buffer) {
|
||||
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
|
||||
}
|
||||
|
||||
async function assetExport(format) {
|
||||
const rows = assetExportRows();
|
||||
async function assetExport(format, companyId) {
|
||||
const rows = assetExportRows(companyId);
|
||||
if (format === 'csv') {
|
||||
return {
|
||||
contentType: 'text/csv',
|
||||
|
||||
@@ -100,7 +100,7 @@ function decorate(row, totals) {
|
||||
};
|
||||
}
|
||||
|
||||
function listParts(query = {}) {
|
||||
function listParts(query = {}, companyId) {
|
||||
const rows = all(
|
||||
`SELECT p.*,
|
||||
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
|
||||
@@ -111,7 +111,8 @@ function listParts(query = {}) {
|
||||
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
|
||||
FROM part_stock GROUP BY part_id
|
||||
) st ON st.part_id = p.id
|
||||
WHERE (? IS NULL OR p.status = ?)
|
||||
WHERE (? IS NULL OR p.entity_id = ?)
|
||||
AND (? IS NULL OR p.status = ?)
|
||||
AND (? IS NULL OR p.category = ?)
|
||||
AND (? IS NULL OR (
|
||||
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
|
||||
@@ -120,6 +121,7 @@ function listParts(query = {}) {
|
||||
))
|
||||
ORDER BY p.name`,
|
||||
[
|
||||
companyId || null, companyId || null,
|
||||
query.status || null, query.status || null,
|
||||
query.category || null, query.category || null,
|
||||
query.search || null, query.search || null, query.search || null,
|
||||
@@ -134,11 +136,11 @@ function listParts(query = {}) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getPart(id) {
|
||||
function getPart(id, companyId) {
|
||||
const row = one(
|
||||
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
|
||||
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`,
|
||||
[id]
|
||||
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ? AND (? IS NULL OR p.entity_id = ?)`,
|
||||
[id, companyId || null, companyId || null]
|
||||
);
|
||||
if (!row) return null;
|
||||
const stock = stockRows(id);
|
||||
@@ -163,28 +165,28 @@ function normalize(body) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function createPart(body, userId) {
|
||||
function createPart(body, userId, companyId) {
|
||||
const input = normalize(body);
|
||||
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
|
||||
if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) {
|
||||
if (one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ?', [input.part_number, companyId])) {
|
||||
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||
}
|
||||
const ts = now();
|
||||
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||
const result = run(
|
||||
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[...cols.map((c) => input[c]), userId, ts, ts]
|
||||
`INSERT INTO parts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
|
||||
);
|
||||
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
|
||||
return getPart(result.lastInsertRowid);
|
||||
return getPart(result.lastInsertRowid, companyId);
|
||||
}
|
||||
|
||||
function updatePart(id, body, userId) {
|
||||
function updatePart(id, body, userId, companyId) {
|
||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||
if (!before) return null;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) return null;
|
||||
const input = normalize({ ...before, ...body });
|
||||
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) {
|
||||
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ? AND id <> ?', [input.part_number, before.entity_id, id])) {
|
||||
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||
}
|
||||
run(
|
||||
@@ -192,12 +194,12 @@ function updatePart(id, body, userId) {
|
||||
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||
);
|
||||
audit(userId, 'update', 'part', id, before, body);
|
||||
return getPart(id);
|
||||
return getPart(id, companyId);
|
||||
}
|
||||
|
||||
function deletePart(id, userId) {
|
||||
function deletePart(id, userId, companyId) {
|
||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||
if (!before) return false;
|
||||
if (!before || (companyId && before.entity_id !== companyId)) return false;
|
||||
run('DELETE FROM parts WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'part', id, before, null);
|
||||
return true;
|
||||
@@ -205,8 +207,12 @@ function deletePart(id, userId) {
|
||||
|
||||
// ---- Stock locations -------------------------------------------------------
|
||||
|
||||
function saveStock(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function partInCompany(partId, companyId) {
|
||||
return Boolean(one('SELECT id FROM parts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [partId, companyId || null, companyId || null]));
|
||||
}
|
||||
|
||||
function saveStock(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
const ts = now();
|
||||
const location = body.location_contact_id || null;
|
||||
const aisle = body.aisle || null;
|
||||
@@ -228,20 +234,21 @@ function saveStock(partId, body, userId) {
|
||||
);
|
||||
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
|
||||
}
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
function deleteStock(partId, stockId, userId) {
|
||||
function deleteStock(partId, stockId, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
|
||||
if (!result.changes) return null;
|
||||
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
// ---- Stock movements -------------------------------------------------------
|
||||
|
||||
function recordTransaction(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function recordTransaction(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
|
||||
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
|
||||
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
|
||||
@@ -272,28 +279,29 @@ function recordTransaction(partId, body, userId) {
|
||||
]
|
||||
);
|
||||
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Photos ----------------------------------------------------------------
|
||||
|
||||
function addPhoto(partId, body, userId) {
|
||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||
function addPhoto(partId, body, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
|
||||
run(
|
||||
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
|
||||
);
|
||||
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
function deletePhoto(partId, photoId, userId) {
|
||||
function deletePhoto(partId, photoId, userId, companyId) {
|
||||
if (!partInCompany(partId, companyId)) return null;
|
||||
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
|
||||
if (!result.changes) return null;
|
||||
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
|
||||
return getPart(partId);
|
||||
return getPart(partId, companyId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -99,8 +99,9 @@ function planFromRow(row, { withPhotoData = true } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPlans() {
|
||||
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
|
||||
function listPlans(companyId) {
|
||||
return all('SELECT * FROM pm_plans WHERE (? IS NULL OR entity_id = ?) ORDER BY name', [companyId || null, companyId || null])
|
||||
.map((row) => planFromRow(row, { withPhotoData: false }));
|
||||
}
|
||||
|
||||
function getPlan(id) {
|
||||
@@ -162,13 +163,14 @@ function resolveEstimatedMinutes(body, fallback) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
function createPlan(body, userId) {
|
||||
function createPlan(body, userId, companyId) {
|
||||
const ts = now();
|
||||
return tx(() => {
|
||||
const result = run(
|
||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO pm_plans (entity_id, name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
companyId || null,
|
||||
body.name || 'PM plan', body.description || null, body.category || null,
|
||||
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
||||
@@ -609,12 +611,14 @@ function runScheduledRecompute() {
|
||||
}
|
||||
|
||||
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||
function pmBookLedger(year) {
|
||||
function pmBookLedger(year, companyId) {
|
||||
const rows = all(
|
||||
`SELECT c.cost, c.completed_at, a.asset_id, a.description
|
||||
FROM pm_completions c
|
||||
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||
JOIN assets a ON a.id = ap.asset_id`
|
||||
JOIN assets a ON a.id = ap.asset_id
|
||||
WHERE (? IS NULL OR a.entity_id = ?)`,
|
||||
[companyId || null, companyId || null]
|
||||
);
|
||||
const byAsset = {};
|
||||
let total = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
|
||||
const { assetExportRows, assetFromRow } = require('./assets');
|
||||
const { ruleSetForBook } = require('./ruleSets');
|
||||
|
||||
function depreciationReport(year, bookType) {
|
||||
function depreciationReport(year, bookType, companyId) {
|
||||
const rules = ruleSetForBook(bookType);
|
||||
const rows = all(`
|
||||
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
||||
@@ -13,9 +13,9 @@ function depreciationReport(year, bookType) {
|
||||
FROM assets a
|
||||
JOIN asset_books b ON b.asset_id = a.id
|
||||
LEFT JOIN entities e ON e.id = a.entity_id
|
||||
WHERE b.active = 1 AND b.book_type = ?
|
||||
WHERE b.active = 1 AND b.book_type = ? AND (? IS NULL OR a.entity_id = ?)
|
||||
ORDER BY a.asset_id
|
||||
`, [bookType]);
|
||||
`, [bookType, companyId || null, companyId || null]);
|
||||
|
||||
const reportRows = rows.map((row) => {
|
||||
const asset = assetFromRow(row);
|
||||
@@ -54,9 +54,9 @@ function depreciationReport(year, bookType) {
|
||||
};
|
||||
}
|
||||
|
||||
function monthlyDepreciationReport(year, bookType) {
|
||||
function monthlyDepreciationReport(year, bookType, companyId) {
|
||||
const rules = ruleSetForBook(bookType);
|
||||
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
|
||||
const assets = all('SELECT * FROM assets WHERE (? IS NULL OR entity_id = ?) ORDER BY asset_id', [companyId || null, companyId || null]).map(assetFromRow);
|
||||
const rows = assets.flatMap((asset) => {
|
||||
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
|
||||
if (!book) return [];
|
||||
@@ -72,8 +72,8 @@ function monthlyDepreciationReport(year, bookType) {
|
||||
return { year, book: bookType, months };
|
||||
}
|
||||
|
||||
function writeDepreciationPdf(res, year, book) {
|
||||
const rows = assetExportRows();
|
||||
function writeDepreciationPdf(res, year, book, companyId) {
|
||||
const rows = assetExportRows(companyId);
|
||||
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
|
||||
|
||||
@@ -1630,6 +1630,142 @@ async function main() {
|
||||
const csvText = await csvResp.text();
|
||||
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
|
||||
|
||||
// ---- Multi-company scoping -------------------------------------------------
|
||||
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
|
||||
// Company A is the seeded default; create company B.
|
||||
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
|
||||
const companyB = (await request('/api/entities', {
|
||||
method: 'POST', headers: auth, body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD' })
|
||||
})).entity.id;
|
||||
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
|
||||
|
||||
// A new company auto-seeds its own book set (distinct ids from company A's).
|
||||
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
|
||||
const booksB = (await request('/api/books', { headers: companyHeaders(companyB) })).books;
|
||||
if (!booksB.some((b) => b.code === 'GAAP')) throw new Error('New company did not get a seeded book set');
|
||||
if (booksA.find((b) => b.code === 'GAAP').id === booksB.find((b) => b.code === 'GAAP').id) {
|
||||
throw new Error('Company B GAAP book is not a distinct per-company record');
|
||||
}
|
||||
|
||||
// Create an asset under each company via the X-Company-Id header.
|
||||
const assetB = (await request('/api/assets', {
|
||||
method: 'POST', headers: companyHeaders(companyB),
|
||||
body: JSON.stringify({ asset_id: `B-${suffix}`, description: 'Company B asset', category: 'Computer Equipment', acquisition_cost: 5000, in_service_date: '2024-01-01' })
|
||||
})).asset;
|
||||
const assetAonly = (await request('/api/assets', {
|
||||
method: 'POST', headers: companyHeaders(companyA),
|
||||
body: JSON.stringify({ asset_id: `A-${suffix}`, description: 'Company A only', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: '2024-01-01' })
|
||||
})).asset;
|
||||
|
||||
// Listings are scoped to the active company.
|
||||
const listA = (await request('/api/assets', { headers: companyHeaders(companyA) })).assets;
|
||||
const listB = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
|
||||
if (listB.some((a) => a.id === assetAonly.id)) throw new Error('Company B sees company A assets');
|
||||
if (!listB.some((a) => a.id === assetB.id)) throw new Error('Company B is missing its own asset');
|
||||
if (listA.some((a) => a.id === assetB.id)) throw new Error('Company A sees company B assets');
|
||||
|
||||
// Cross-company asset access is blocked (company A cannot fetch company B's asset).
|
||||
const crossFetch = await fetch(`${baseUrl}/api/assets/${assetB.id}`, { headers: companyHeaders(companyA) });
|
||||
if (crossFetch.status !== 404) throw new Error('Cross-company asset fetch should 404');
|
||||
|
||||
// PM plans are per-company.
|
||||
await request('/api/pm-plans', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Plan ${suffix}`, frequency_value: 3, frequency_unit: 'months' }) });
|
||||
const plansB = (await request('/api/pm-plans', { headers: companyHeaders(companyB) })).plans;
|
||||
const plansA = (await request('/api/pm-plans', { headers: companyHeaders(companyA) })).plans;
|
||||
if (!plansB.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company B PM plan missing');
|
||||
if (plansA.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company A sees company B PM plan');
|
||||
|
||||
// Dashboards differ per company.
|
||||
const dashA = (await request('/api/dashboard', { headers: companyHeaders(companyA) })).stats;
|
||||
const dashB = (await request('/api/dashboard', { headers: companyHeaders(companyB) })).stats;
|
||||
if (dashB.asset_count !== 1) throw new Error(`Company B dashboard should count exactly its 1 asset (got ${dashB.asset_count})`);
|
||||
if (dashA.asset_count <= dashB.asset_count) throw new Error('Company A (seeded data) should have more assets than the new company B');
|
||||
|
||||
// Dispose company B: it leaves the active list but its data is preserved; then restore it.
|
||||
await request(`/api/entities/${companyB}/dispose`, { method: 'POST', headers: auth, body: JSON.stringify({ reason: 'smoke test' }) });
|
||||
const activeAfter = (await request('/api/entities', { headers: auth })).entities;
|
||||
if (activeAfter.some((e) => e.id === companyB)) throw new Error('Disposed company still appears in the active list');
|
||||
const allAfter = (await request('/api/entities?includeDisposed=1', { headers: auth })).entities;
|
||||
const disposedRow = allAfter.find((e) => e.id === companyB);
|
||||
if (!disposedRow || disposedRow.status !== 'disposed') throw new Error('Disposed company missing from includeDisposed list');
|
||||
await request(`/api/entities/${companyB}/restore`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
|
||||
const restoredAssets = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
|
||||
if (!restoredAssets.some((a) => a.id === assetB.id)) throw new Error('Company B asset was lost across dispose/restore');
|
||||
|
||||
// ---- Phase 2: reference data / contacts / templates are per-company ---------
|
||||
// A new company auto-seeds the standard categories.
|
||||
const seededCatsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
|
||||
if (!seededCatsB.some((c) => c.name === 'Computer Equipment')) throw new Error('New company did not auto-seed reference categories');
|
||||
|
||||
await request('/api/asset-categories', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Category ${suffix}` }) });
|
||||
const catsA = (await request('/api/asset-categories', { headers: companyHeaders(companyA) })).categories;
|
||||
const catsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
|
||||
if (!catsB.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company B category missing');
|
||||
if (catsA.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company A sees company B category');
|
||||
|
||||
// Contacts are per-company.
|
||||
await request('/api/contacts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ type: 'vendor', organization: `B Vendor ${suffix}` }) });
|
||||
const contactsA = (await request('/api/contacts', { headers: companyHeaders(companyA) })).contacts;
|
||||
const contactsB = (await request('/api/contacts', { headers: companyHeaders(companyB) })).contacts;
|
||||
if (!contactsB.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company B contact missing');
|
||||
if (contactsA.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company A sees company B contact');
|
||||
|
||||
// Templates are per-company.
|
||||
await request('/api/templates', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Template ${suffix}`, defaults: {}, custom_fields: [] }) });
|
||||
const tplA = (await request('/api/templates', { headers: companyHeaders(companyA) })).templates;
|
||||
const tplB = (await request('/api/templates', { headers: companyHeaders(companyB) })).templates;
|
||||
if (!tplB.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company B template missing');
|
||||
if (tplA.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company A sees company B template');
|
||||
|
||||
// ---- Per-company user access control ---------------------------------------
|
||||
const limitedEmail = `limited-${suffix}@example.com`;
|
||||
const limitedPassword = 'Acc3ssControl!2026X';
|
||||
await request('/api/users', {
|
||||
method: 'POST', headers: companyHeaders(companyA),
|
||||
body: JSON.stringify({ name: 'Limited Person', email: limitedEmail, password: limitedPassword, role: 'viewer', company_ids: [companyA] })
|
||||
});
|
||||
const limitedLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: limitedEmail, password: limitedPassword }) });
|
||||
const limitedAuth = { Authorization: `Bearer ${limitedLogin.token}` };
|
||||
|
||||
// The switcher only lists company A for this user.
|
||||
const limitedCompanies = (await request('/api/entities', { headers: limitedAuth })).entities;
|
||||
if (limitedCompanies.length !== 1 || limitedCompanies[0].id !== companyA) throw new Error('Limited user can see companies beyond their assignment');
|
||||
|
||||
// Forging X-Company-Id to company B falls back to company A (no leak of B's data).
|
||||
const limitedBView = (await request('/api/assets', { headers: { ...limitedAuth, 'X-Company-Id': String(companyB) } })).assets;
|
||||
if (limitedBView.some((a) => a.id === assetB.id)) throw new Error('Limited user accessed another company by forging the header');
|
||||
if (!limitedBView.some((a) => a.id === assetAonly.id)) throw new Error('Limited user did not fall back to their permitted company');
|
||||
|
||||
// Admins still see every active company.
|
||||
const adminCompanies = (await request('/api/entities', { headers: auth })).entities;
|
||||
if (!adminCompanies.some((e) => e.id === companyA) || !adminCompanies.some((e) => e.id === companyB)) {
|
||||
throw new Error('Admin should see all active companies');
|
||||
}
|
||||
|
||||
// ---- Parts + assignments are per-company -----------------------------------
|
||||
// The same part number is allowed in different companies (per-company uniqueness).
|
||||
const partB = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'B Part' }) })).part;
|
||||
const partA = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'A Part' }) })).part;
|
||||
if (!partA || !partB || partA.id === partB.id) throw new Error('Per-company part-number uniqueness failed');
|
||||
const partsA = (await request('/api/parts', { headers: companyHeaders(companyA) })).parts;
|
||||
const partsB = (await request('/api/parts', { headers: companyHeaders(companyB) })).parts;
|
||||
if (partsA.some((p) => p.id === partB.id)) throw new Error('Company A sees company B part');
|
||||
if (!partsB.some((p) => p.id === partB.id)) throw new Error('Company B part missing');
|
||||
const crossPart = await fetch(`${baseUrl}/api/parts/${partB.id}`, { headers: companyHeaders(companyA) });
|
||||
if (crossPart.status !== 404) throw new Error('Cross-company part fetch should 404');
|
||||
|
||||
// Assignments scope to the assigned asset's company.
|
||||
await request('/api/assignments', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ asset_id: assetB.id }) });
|
||||
const assignsB = (await request('/api/assignments', { headers: companyHeaders(companyB) })).assignments;
|
||||
const assignsA = (await request('/api/assignments', { headers: companyHeaders(companyA) })).assignments;
|
||||
if (!assignsB.some((a) => a.asset_id === assetB.id)) throw new Error('Company B assignment missing');
|
||||
if (assignsA.some((a) => a.asset_id === assetB.id)) throw new Error('Company A sees company B assignment');
|
||||
// Assigning another company's asset is rejected.
|
||||
const crossAssign = await fetch(`${baseUrl}/api/assignments`, {
|
||||
method: 'POST', headers: { ...companyHeaders(companyA), 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetB.id })
|
||||
});
|
||||
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
|
||||
|
||||
console.log('Smoke test passed');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user