Added Multi-Company Support

This commit is contained in:
2026-06-08 00:54:21 -05:00
parent c164395915
commit db614a6707
32 changed files with 1133 additions and 323 deletions

View File

@@ -7,6 +7,7 @@ const path = require('path');
const { DB_PATH, initialize } = require('./db'); const { DB_PATH, initialize } = require('./db');
const { createCorsMiddleware } = require('./middleware/cors'); const { createCorsMiddleware } = require('./middleware/cors');
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
const { resolveCompany } = require('./middleware/company');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const alertRoutes = require('./routes/alerts'); const alertRoutes = require('./routes/alerts');
@@ -46,6 +47,7 @@ function createApp() {
app.use('/api', authRoutes); app.use('/api', authRoutes);
app.use('/api', requireAuth); app.use('/api', requireAuth);
app.use('/api', resolveCompany);
app.use('/api', profileRoutes); app.use('/api', profileRoutes);
app.use('/api', dashboardRoutes); app.use('/api', dashboardRoutes);
app.use('/api', referenceRoutes); app.use('/api', referenceRoutes);

View File

@@ -108,6 +108,12 @@ function initialize() {
updated_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -115,17 +121,21 @@ function initialize() {
tax_id TEXT, tax_id TEXT,
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12, fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
base_currency TEXT NOT NULL DEFAULT 'USD', base_currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'active',
disposed_at TEXT,
disposed_reason TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS reference_values ( CREATE TABLE IF NOT EXISTS reference_values (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
type TEXT NOT NULL, type TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
code TEXT, code TEXT,
metadata TEXT, metadata TEXT,
UNIQUE(type, name) UNIQUE(entity_id, type, name)
); );
CREATE TABLE IF NOT EXISTS employees ( CREATE TABLE IF NOT EXISTS employees (
@@ -207,12 +217,14 @@ function initialize() {
CREATE TABLE IF NOT EXISTS asset_templates ( CREATE TABLE IF NOT EXISTS asset_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, entity_id INTEGER REFERENCES entities(id),
name TEXT NOT NULL,
description TEXT, description TEXT,
defaults TEXT NOT NULL, defaults TEXT NOT NULL,
custom_fields TEXT NOT NULL, custom_fields TEXT NOT NULL,
created_at 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 ( CREATE TABLE IF NOT EXISTS tax_rule_sets (
@@ -328,6 +340,7 @@ function initialize() {
CREATE TABLE IF NOT EXISTS saved_reports ( CREATE TABLE IF NOT EXISTS saved_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
name TEXT NOT NULL, name TEXT NOT NULL,
report_type TEXT NOT NULL, report_type TEXT NOT NULL,
options_json TEXT NOT NULL, options_json TEXT NOT NULL,
@@ -382,7 +395,8 @@ function initialize() {
CREATE TABLE IF NOT EXISTS books ( CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, entity_id INTEGER REFERENCES entities(id),
code TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
book_type TEXT NOT NULL DEFAULT 'tax', book_type TEXT NOT NULL DEFAULT 'tax',
@@ -394,7 +408,8 @@ function initialize() {
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12, fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, 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 ( CREATE TABLE IF NOT EXISTS alerts (
@@ -528,6 +543,7 @@ function initialize() {
CREATE TABLE IF NOT EXISTS contacts ( CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
type TEXT NOT NULL DEFAULT 'other', type TEXT NOT NULL DEFAULT 'other',
first_name TEXT, first_name TEXT,
last_name TEXT, last_name TEXT,
@@ -571,7 +587,8 @@ function initialize() {
CREATE TABLE IF NOT EXISTS parts ( CREATE TABLE IF NOT EXISTS parts (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, name TEXT NOT NULL,
description TEXT, description TEXT,
category TEXT, category TEXT,
@@ -588,7 +605,8 @@ function initialize() {
notes TEXT, notes TEXT,
created_by INTEGER REFERENCES users(id), created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL, 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 ( CREATE TABLE IF NOT EXISTS part_stock (
@@ -759,6 +777,154 @@ function migrate() {
ensureColumn('users', 'last_login_at', 'TEXT'); ensureColumn('users', 'last_login_at', 'TEXT');
ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0'); ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
dropUsersRoleCheck(); 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. // 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'); 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() { function seed() {
const createdAt = now(); const createdAt = now();
@@ -915,31 +1137,8 @@ function seed() {
); );
} }
const refs = [ const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
['location', 'Headquarters', 'HQ'], if (refEntity) seedReferenceValuesForCompany(refEntity.id);
['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],
'{}'
]);
}
run( run(
`INSERT OR IGNORE INTO employees ( `INSERT OR IGNORE INTO employees (
@@ -1005,32 +1204,10 @@ function seed() {
); );
} }
if (!one('SELECT id FROM books LIMIT 1')) { // Seed the standard book set for the default company. Each company gets its own set; see
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1"); // seedBooksForCompany (also called when a new company is created).
const ruleId = federalRule ? federalRule.id : null; const defaultEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
const bookDefs = [ if (defaultEntity) seedBooksForCompany(defaultEntity.id);
['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]
);
const settings = { const settings = {
server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost', server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost',
@@ -1091,5 +1268,7 @@ module.exports = {
one, one,
parseJson, parseJson,
run, run,
seedBooksForCompany,
seedReferenceValuesForCompany,
tx tx
}; };

View 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 };

View File

@@ -17,8 +17,12 @@ const userSelect = `
LEFT JOIN teams t ON t.id = u.team_id LEFT JOIN teams t ON t.id = u.team_id
`; `;
const companies = require('../services/companies');
function publicAdminUser(id) { 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) { function validateRole(role) {
@@ -45,7 +49,8 @@ router.put('/settings', requireCapability('admin.settings'), (req, res) => {
}); });
router.get('/users', requireCapability('admin.users'), (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) => { 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); 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]' }); audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) }); 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); 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); const user = publicAdminUser(req.params.id);
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined }); audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
return res.json({ user }); return res.json({ user });

View File

@@ -8,12 +8,12 @@ const { submitTicket } = require('../services/servicenow');
const router = express.Router(); const router = express.Router();
router.get('/alerts', (req, res) => { 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) => { router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
const result = scanAlerts(req.user.id); 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) => { router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {

View File

@@ -21,48 +21,48 @@ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16
const router = express.Router(); const router = express.Router();
router.get('/assets', (req, res) => { 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) => { 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) => { router.get('/assets/lookup', (req, res) => {
const code = String(req.query.code || '').trim(); const code = String(req.query.code || '').trim();
if (!code) return res.status(400).json({ error: 'Scan code is required' }); 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}"` }); if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
return res.json({ asset }); return res.json({ asset });
}); });
router.get('/assets/:id', (req, res) => { router.get('/assets/:id', (req, res) => {
const asset = hydrateAsset(req.params.id); 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 }); return res.json({ asset });
}); });
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => { 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' }); if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset }); return res.json({ asset });
}); });
router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => { router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => {
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' }); if (!deleteAsset(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Asset not found' });
return res.status(204).end(); return res.status(204).end();
}); });
router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => { router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : []; 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' }); if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
return res.json({ updated }); return res.json({ updated });
}); });
router.get('/assets/:id/depreciation', (req, res) => { router.get('/assets/:id/depreciation', (req, res) => {
const asset = hydrateAsset(req.params.id); 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 startYear = Number(req.query.startYear || new Date().getFullYear());
const endYear = Number(req.query.endYear || startYear + 5); const endYear = Number(req.query.endYear || startYear + 5);
const schedules = asset.books const schedules = asset.books

View File

@@ -21,25 +21,25 @@ router.post('/employees', requireCapability('assets.assign'), (req, res) => {
router.get('/assignments', (req, res) => { router.get('/assignments', (req, res) => {
res.json({ res.json({
summary: assignmentSummary(), summary: assignmentSummary(req.companyId),
assignments: assignmentRows({ assignments: assignmentRows({
asset_id: req.query.asset_id || null, asset_id: req.query.asset_id || null,
employee_id: req.query.employee_id || null, employee_id: req.query.employee_id || null,
active: req.query.active === 'true' active: req.query.active === 'true'
}) }, req.companyId)
}); });
}); });
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => { router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
try { 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) { } catch (error) {
next(error); next(error);
} }
}); });
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => { 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' }); if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
return res.json({ assignment }); return res.json({ assignment });
}); });

View File

@@ -6,34 +6,34 @@ const { exportReport } = require('../services/reportExport');
const router = express.Router(); const router = express.Router();
router.get('/books', (req, res) => { router.get('/books', (req, res) => {
res.json(listBooks()); res.json(listBooks(req.companyId));
}); });
router.post('/books', requireCapability('finance.manage'), (req, res) => { 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) => { 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' }); if (!book) return res.status(404).json({ error: 'Book not found' });
return res.json({ book }); return res.json({ book });
}); });
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => { 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(); return res.status(204).end();
}); });
router.get('/books/:code/ledger', (req, res) => { router.get('/books/:code/ledger', (req, res) => {
const year = Number(req.query.year) || new Date().getFullYear(); 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' }); if (!ledger) return res.status(404).json({ error: 'Book not found' });
return res.json({ ledger }); return res.json({ ledger });
}); });
router.post('/books/:code/ledger/export', async (req, res) => { router.post('/books/:code/ledger/export', async (req, res) => {
const year = Number(req.body.year) || new Date().getFullYear(); 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' }); if (!report) return res.status(404).json({ error: 'Book not found' });
const format = String(req.body.format || 'csv').toLowerCase(); const format = String(req.body.format || 'csv').toLowerCase();
const output = await exportReport(report, format); const output = await exportReport(report, format);

View File

@@ -14,39 +14,39 @@ const router = express.Router();
const editor = requireCapability('contacts.manage'); const editor = requireCapability('contacts.manage');
router.get('/contacts', (req, res) => { 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) => { 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' }); if (!contact) return res.status(404).json({ error: 'Contact not found' });
return res.json({ contact }); return res.json({ contact });
}); });
router.post('/contacts', editor, (req, res) => { 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) => { 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' }); if (!contact) return res.status(404).json({ error: 'Contact not found' });
return res.json({ contact }); return res.json({ contact });
}); });
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => { 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(); return res.status(204).end();
}); });
router.post('/contacts/:id/notes', editor, (req, res) => { router.post('/contacts/:id/notes', editor, (req, res) => {
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' }); 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' }); if (!note) return res.status(404).json({ error: 'Contact not found' });
return res.status(201).json({ note }); return res.status(201).json({ note });
}); });
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => { 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(); return res.status(204).end();
}); });

View File

@@ -4,24 +4,25 @@ const { all, one } = require('../db');
const router = express.Router(); const router = express.Router();
router.get('/dashboard', (req, res) => { router.get('/dashboard', (req, res) => {
const cid = req.companyId || null;
const stats = one(` const stats = one(`
SELECT SELECT
COUNT(*) AS asset_count, COUNT(*) AS asset_count,
COALESCE(SUM(acquisition_cost), 0) AS total_cost, 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 = '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 COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
FROM assets 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 GROUP BY category ORDER BY value DESC'); 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 GROUP BY status ORDER BY count DESC'); 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(` const upcomingTasks = all(`
SELECT t.*, a.asset_id, a.description SELECT t.*, a.asset_id, a.description
FROM tasks t FROM tasks t
LEFT JOIN assets a ON a.id = t.asset_id 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 ORDER BY t.due_date IS NULL, t.due_date
LIMIT 8 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'); 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 }); res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
}); });

View File

@@ -10,7 +10,7 @@ const router = express.Router();
router.get('/export/assets', async (req, res) => { router.get('/export/assets', async (req, res) => {
const format = String(req.query.format || 'json').toLowerCase(); 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-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
return res.send(output.body); 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) => { 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' }); if (!req.file) return res.status(400).json({ error: 'File is required' });
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer); 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 }); audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
res.json({ imported }); res.json({ imported });
}); });

View File

@@ -17,59 +17,59 @@ const router = express.Router();
const editor = requireCapability('parts.manage'); const editor = requireCapability('parts.manage');
router.get('/parts', (req, res) => { 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) => { 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' }); if (!part) return res.status(404).json({ error: 'Part not found' });
return res.json({ part }); return res.json({ part });
}); });
router.post('/parts', editor, (req, res) => { 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) => { 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' }); if (!part) return res.status(404).json({ error: 'Part not found' });
return res.json({ part }); return res.json({ part });
}); });
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => { 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(); return res.status(204).end();
}); });
// Stock locations (aisle/bin + on-hand/reserved per location) // Stock locations (aisle/bin + on-hand/reserved per location)
router.post('/parts/:id/stock', editor, (req, res) => { 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' }); if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
return res.status(201).json({ part }); return res.status(201).json({ part });
}); });
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => { 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' }); if (!part) return res.status(404).json({ error: 'Stock location not found' });
return res.json({ part }); return res.json({ part });
}); });
// Stock movements (receive/issue/adjust/reserve/unreserve) // Stock movements (receive/issue/adjust/reserve/unreserve)
router.post('/parts/:id/transactions', editor, (req, res) => { 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' }); if (!part) return res.status(404).json({ error: 'Part not found' });
return res.status(201).json({ part }); return res.status(201).json({ part });
}); });
// Photos // Photos
router.post('/parts/:id/photos', editor, (req, res) => { 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' }); if (!part) return res.status(404).json({ error: 'Part not found' });
return res.status(201).json({ part }); return res.status(201).json({ part });
}); });
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => { 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' }); if (!part) return res.status(404).json({ error: 'Photo not found' });
return res.json({ part }); return res.json({ part });
}); });

View File

@@ -25,7 +25,7 @@ const assetPm = requireCapability('pm.complete'); // attach to assets & complete
// PM plan templates // PM plan templates
router.get('/pm-plans', (req, res) => { router.get('/pm-plans', (req, res) => {
res.json({ plans: listPlans() }); res.json({ plans: listPlans(req.companyId) });
}); });
router.get('/pm-plans/:id', (req, res) => { 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) => { 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) => { router.put('/pm-plans/:id', planEditor, (req, res) => {

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db'); const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireCapability } = require('../middleware/auth'); const { requireCapability } = require('../middleware/auth');
const { formatAssetId } = require('../services/assets'); const { formatAssetId } = require('../services/assets');
const companies = require('../services/companies');
const assetClasses = require('../services/assetClasses'); const assetClasses = require('../services/assetClasses');
const zones = require('../services/zones'); const zones = require('../services/zones');
const section179Limits = require('../services/section179Limits'); const section179Limits = require('../services/section179Limits');
@@ -95,30 +96,43 @@ router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, re
return res.status(204).end(); 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) => { 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) => { router.post('/entities', requireCapability('config.manage'), (req, res, next) => {
const created = now(); try {
const result = run( res.status(201).json({ entity: companies.createCompany(req.body, req.user.id) });
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', } catch (error) {
[ next(error);
req.body.name, }
req.body.legal_name || null, });
req.body.tax_id || null,
Number(req.body.fiscal_year_end_month || 12), router.put('/entities/:id', requireCapability('config.manage'), (req, res) => {
req.body.base_currency || 'USD', const entity = companies.updateCompany(req.params.id, req.body, req.user.id);
created, if (!entity) return res.status(404).json({ error: 'Company not found' });
created return res.json({ entity });
] });
);
audit(req.user.id, 'create', 'entity', result.lastInsertRowid, null, req.body); router.post('/entities/:id/dispose', requireCapability('config.manage'), (req, res) => {
res.status(201).json({ entity: one('SELECT * FROM entities WHERE id = ?', [result.lastInsertRowid]) }); 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) => { 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, {}) })) }); 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_id: template ? templateId : null,
id_template_name: template ? template.name : null, id_template_name: template ? template.name : null,
id_template_pattern: template ? template.pattern : 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'); const categoryEditor = requireCapability('config.manage');
router.get('/asset-categories', (req, res) => { 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) }); res.json({ categories: rows.map(categoryRow) });
}); });
router.post('/asset-categories', categoryEditor, (req, res) => { router.post('/asset-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); return res.status(400).json({ error: 'That category already exists' });
} }
const { classId, classNumber } = resolveCategoryClass(req.body); const { classId, classNumber } = resolveCategoryClass(req.body);
const result = run( const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)", "INSERT INTO reference_values (entity_id, 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 })] [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 }); 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])) }); return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
}); });
router.put('/asset-categories/:id', categoryEditor, (req, res) => { 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' }); if (!before) return res.status(404).json({ error: 'Category not found' });
const name = String(req.body.name ?? before.name).trim(); const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); return res.status(400).json({ error: 'That category already exists' });
} }
const meta = parseJson(before.metadata, {}); 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]); run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
let renamed = 0; let renamed = 0;
if (name !== before.name) { 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) { 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 }); 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 }); 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) => { 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' }); 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]); run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected }); audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
return res.json({ deleted: true, asset_count: affected }); return res.json({ deleted: true, asset_count: affected });
@@ -230,50 +244,50 @@ function departmentRow(row) {
id: row.id, id: row.id,
name: row.name, name: row.name,
code: row.code, 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) => { 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) }); res.json({ departments: rows.map(departmentRow) });
}); });
router.post('/asset-departments', categoryEditor, (req, res) => { router.post('/asset-departments', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Department name is required' }); 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' }); return res.status(400).json({ error: 'That department already exists' });
} }
const result = run( const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')", "INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'department', ?, ?, '{}')",
[name, req.body.code || null] [req.companyId, name, req.body.code || null]
); );
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name }); 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])) }); return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
}); });
router.put('/asset-departments/:id', categoryEditor, (req, res) => { 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' }); if (!before) return res.status(404).json({ error: 'Department not found' });
const name = String(req.body.name ?? before.name).trim(); const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Department name is required' }); 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' }); 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]); run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
let renamed = 0; let renamed = 0;
if (name !== before.name) { 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 }); 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 }); 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) => { 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' }); 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]); run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected }); audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
return res.json({ deleted: true, asset_count: affected }); return res.json({ deleted: true, asset_count: affected });
@@ -287,50 +301,50 @@ function pmCategoryRow(row) {
id: row.id, id: row.id,
name: row.name, name: row.name,
code: row.code, 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) => { 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) }); res.json({ categories: rows.map(pmCategoryRow) });
}); });
router.post('/pm-categories', categoryEditor, (req, res) => { router.post('/pm-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); return res.status(400).json({ error: 'That PM category already exists' });
} }
const result = run( const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')", "INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'pm_category', ?, ?, '{}')",
[name, req.body.code || null] [req.companyId, name, req.body.code || null]
); );
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name }); 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])) }); return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
}); });
router.put('/pm-categories/:id', categoryEditor, (req, res) => { 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' }); if (!before) return res.status(404).json({ error: 'PM category not found' });
const name = String(req.body.name ?? before.name).trim(); const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); 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]); run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
let renamed = 0; let renamed = 0;
if (name !== before.name) { 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 }); 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 }); 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) => { 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' }); 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]); run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected }); audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
return res.json({ deleted: true, plan_count: affected }); return res.json({ deleted: true, plan_count: affected });
@@ -349,30 +363,30 @@ function partCategoryRow(row) {
} }
router.get('/part-categories', (req, res) => { 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) }); res.json({ categories: rows.map(partCategoryRow) });
}); });
router.post('/part-categories', categoryEditor, (req, res) => { router.post('/part-categories', categoryEditor, (req, res) => {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); return res.status(400).json({ error: 'That part category already exists' });
} }
const result = run( const result = run(
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')", "INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'part_category', ?, ?, '{}')",
[name, req.body.code || null] [req.companyId, name, req.body.code || null]
); );
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name }); 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])) }); return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
}); });
router.put('/part-categories/:id', categoryEditor, (req, res) => { 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' }); if (!before) return res.status(404).json({ error: 'Part category not found' });
const name = String(req.body.name ?? before.name).trim(); const name = String(req.body.name ?? before.name).trim();
if (!name) return res.status(400).json({ error: 'Category name is required' }); 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' }); 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]); 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) => { 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' }); 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; const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]); run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);

View File

@@ -18,12 +18,12 @@ router.get('/reports/catalog', (req, res) => {
}); });
router.post('/reports/run', (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) => { router.post('/reports/export', async (req, res) => {
const format = String(req.body.format || 'csv').toLowerCase(); 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 output = await exportReport(report, format);
const extension = EXPORT_EXTENSIONS[format] || 'txt'; const extension = EXPORT_EXTENSIONS[format] || 'txt';
res.setHeader('Content-Type', output.contentType); res.setHeader('Content-Type', output.contentType);
@@ -34,15 +34,16 @@ router.post('/reports/export', async (req, res) => {
router.get('/saved-reports', (req, res) => { router.get('/saved-reports', (req, res) => {
const reports = all( const reports = all(
`SELECT s.*, u.name AS created_by_name FROM saved_reports s `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, {}) })); ).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
res.json({ reports }); res.json({ reports });
}); });
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => { router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
const result = run( const result = run(
'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO saved_reports (entity_id, 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()] [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); audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]); 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) => { 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' }); 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); audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
return res.status(204).end(); 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) => { router.get('/reports/depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear()); const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP'; 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) => { router.get('/reports/monthly-depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear()); const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP'; 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) => { router.get('/reports/depreciation.pdf', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear()); const year = Number(req.query.year || new Date().getFullYear());
const book = req.query.book || 'GAAP'; const book = req.query.book || 'GAAP';
writeDepreciationPdf(res, year, book); writeDepreciationPdf(res, year, book, req.companyId);
}); });
module.exports = router; module.exports = router;

View File

@@ -7,7 +7,8 @@ const router = express.Router();
router.get('/templates', (req, res) => { router.get('/templates', (req, res) => {
const templates = all( const templates = all(
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count `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) => ({ ).map((row) => ({
...row, ...row,
defaults: parseJson(row.defaults, {}), defaults: parseJson(row.defaults, {}),
@@ -19,15 +20,15 @@ router.get('/templates', (req, res) => {
router.post('/templates', requireCapability('config.manage'), (req, res) => { router.post('/templates', requireCapability('config.manage'), (req, res) => {
const created = now(); const created = now();
const result = run( const result = run(
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO asset_templates (entity_id, 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] [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); 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]) }); res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
}); });
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => { 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' }); if (!before) return res.status(404).json({ error: 'Template not found' });
run( run(
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?', '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) // Deleting a template unlinks it from any assets (their stored field values are kept)
// and removes it from the picker for future assets. // and removes it from the picker for future assets.
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => { 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' }); 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; const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]); run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);

View File

@@ -78,20 +78,22 @@ function scanAlerts(userId) {
for (const c of cands) { for (const c of cands) {
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`); 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]); 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) {
if (existing.status === 'dismissed') continue; if (existing.status === 'dismissed') continue;
run( 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 = ? status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
WHERE id = ?`, 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 { } else {
const result = run( const result = run(
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at) `INSERT INTO alerts (type, reference_type, reference_id, asset_id, entity_id, severity, title, message, due_date, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts] [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])); created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
} }
@@ -113,14 +115,17 @@ function listAlerts(query = {}) {
FROM alerts al FROM alerts al
LEFT JOIN assets a ON a.id = al.asset_id LEFT JOIN assets a ON a.id = al.asset_id
LEFT JOIN users u ON u.id = al.acknowledged_by 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`, 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() { function summary(companyId) {
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity"); 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'); const open = rows.filter((r) => r.status === 'open');
return { return {
open: open.reduce((s, r) => s + r.count, 0), open: open.reduce((s, r) => s + r.count, 0),

View File

@@ -44,9 +44,12 @@ const assetColumns = [
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER']; const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book). // Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
function enabledBookCodes() { function enabledBookCodes(companyId) {
try { 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); if (rows.length) return rows.map((row) => row.code);
} catch { } catch {
// books table may not exist on a very old database // books table may not exist on a very old database
@@ -121,17 +124,18 @@ function hydrateAsset(id) {
return asset; return asset;
} }
function lookupAssetByCode(code) { function lookupAssetByCode(code, companyId) {
const value = String(code || '').trim(); const value = String(code || '').trim();
if (!value) return null; if (!value) return null;
const row = one( const row = one(
`SELECT id FROM assets `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 asset_id = ? COLLATE NOCASE
OR serial_number = ? COLLATE NOCASE OR serial_number = ? COLLATE NOCASE)
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
LIMIT 1`, LIMIT 1`,
[value, value, value, value] [companyId || null, companyId || null, value, value, value, value]
); );
return row ? hydrateAsset(row.id) : null; return row ? hydrateAsset(row.id) : null;
} }
@@ -212,8 +216,8 @@ function normalizeAssetInput(body) {
return input; return input;
} }
function createBooks(assetId, asset, books = []) { function createBooks(assetId, asset, books = [], companyId) {
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book })); const selectedBooks = books.length ? books : enabledBookCodes(companyId).map((book) => ({ book_type: book }));
for (const book of selectedBooks) { for (const book of selectedBooks) {
run( run(
`INSERT OR REPLACE INTO asset_books ( `INSERT OR REPLACE INTO asset_books (
@@ -262,9 +266,10 @@ function listAssets(query = {}) {
]).map(assetFromRow); ]).map(assetFromRow);
} }
function createAsset(body, userId) { function createAsset(body, userId, companyId) {
const created = now(); const created = now();
const input = normalizeAssetInput(body); const input = normalizeAssetInput(body);
if (companyId) input.entity_id = companyId; // new assets belong to the active company
const result = tx(() => { const result = tx(() => {
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
const insert = run( const insert = run(
@@ -272,7 +277,7 @@ function createAsset(body, userId) {
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`, VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, created, created] [...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]); run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
return insert; return insert;
}); });
@@ -281,11 +286,13 @@ function createAsset(body, userId) {
return asset; return asset;
} }
function updateAsset(id, body, userId) { function updateAsset(id, body, userId, companyId) {
const before = hydrateAsset(id); const before = hydrateAsset(id);
if (!before) return null; if (!before) return null;
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
const input = normalizeAssetInput({ ...before, ...body }); 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. // When depreciation-critical fields change, the caller chooses when the change applies.
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation; // 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
// 'going_forward' keeps depreciation already recorded (the stored 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 = ?`, `UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), updated, 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]); run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
}); });
@@ -305,17 +312,23 @@ function updateAsset(id, body, userId) {
return asset; return asset;
} }
function deleteAsset(id, userId) { function deleteAsset(id, userId, companyId) {
const before = hydrateAsset(id); const before = hydrateAsset(id);
if (!before) return false; if (!before) return false;
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
run('DELETE FROM assets WHERE id = ?', [id]); run('DELETE FROM assets WHERE id = ?', [id]);
audit(userId, 'delete', 'asset', id, before, null); audit(userId, 'delete', 'asset', id, before, null);
return true; 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 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)); 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; if (!ids.length || !columns.length) return 0;
tx(() => { tx(() => {
@@ -330,26 +343,29 @@ function massUpdateAssets(ids, changes, userId) {
return ids.length; return ids.length;
} }
function assetExportRows() { function assetExportRows(companyId) {
return all(` return all(`
SELECT a.*, e.name AS entity_name SELECT a.*, e.name AS entity_name
FROM assets a FROM assets a
LEFT JOIN entities e ON e.id = a.entity_id LEFT JOIN entities e ON e.id = a.entity_id
WHERE (? IS NULL OR a.entity_id = ?)
ORDER BY a.asset_id ORDER BY a.asset_id
`).map((asset) => ({ `, [companyId || null, companyId || null]).map((asset) => ({
...asset, ...asset,
listed_property: Boolean(asset.listed_property), listed_property: Boolean(asset.listed_property),
custom_fields: parseJson(asset.custom_fields, {}) custom_fields: parseJson(asset.custom_fields, {})
})); }));
} }
function upsertImportedAssets(rows, userId) { function upsertImportedAssets(rows, userId, companyId) {
let imported = 0; let imported = 0;
tx(() => { tx(() => {
for (const row of rows) { for (const row of rows) {
const input = normalizeAssetInput(row); 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); 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); const classNumber = assetClassNumberForCategory(input.category);
if (existing) { if (existing) {
run( run(
@@ -362,7 +378,7 @@ function upsertImportedAssets(rows, userId) {
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`, VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()] [...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]); run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
} }
imported += 1; imported += 1;

View File

@@ -87,19 +87,21 @@ function upsertEmployee(employee) {
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]); return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
} }
function assignmentRows(query = {}) { function assignmentRows(query = {}, companyId) {
return all(` return all(`
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description, 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 e.name AS employee_name, e.email AS employee_email, e.employee_number
FROM asset_assignments aa FROM asset_assignments aa
JOIN assets a ON a.id = aa.asset_id JOIN assets a ON a.id = aa.asset_id
LEFT JOIN employees e ON e.id = aa.employee_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.employee_id = ?)
AND (? IS NULL OR aa.released_at IS NULL) AND (? IS NULL OR aa.released_at IS NULL)
ORDER BY aa.assigned_at DESC, aa.id DESC ORDER BY aa.assigned_at DESC, aa.id DESC
LIMIT 500 LIMIT 500
`, [ `, [
companyId || null, companyId || null,
query.asset_id || null, query.asset_id || null,
query.asset_id || null, query.asset_id || null,
query.employee_id || null, query.employee_id || null,
@@ -108,9 +110,10 @@ function assignmentRows(query = {}) {
]).map(assignmentFromRow); ]).map(assignmentFromRow);
} }
function assignAsset(body, userId) { function assignAsset(body, userId, companyId) {
const timestamp = now(); 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 }); 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; 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] [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); audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]); return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
}); });
} }
function releaseAssignment(id, body, userId) { function releaseAssignment(id, body, userId, companyId) {
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]); const before = one(
if (!before) return null; '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(); const timestamp = now();
run( run(
`UPDATE asset_assignments `UPDATE asset_assignments
@@ -171,26 +177,28 @@ function releaseAssignment(id, body, userId) {
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id] [timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
); );
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body }); 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(` const stats = one(`
SELECT SELECT
COUNT(*) AS active_assignments, COUNT(*) AS active_assignments,
COUNT(DISTINCT employee_id) AS assigned_employees, COUNT(DISTINCT aa.employee_id) AS assigned_employees,
COUNT(DISTINCT location) AS assigned_locations COUNT(DISTINCT aa.location) AS assigned_locations
FROM asset_assignments FROM asset_assignments aa
WHERE released_at IS NULL 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(` const unassigned = one(`
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM assets a FROM assets a
WHERE NOT EXISTS ( WHERE (? IS NULL OR a.entity_id = ?)
AND NOT EXISTS (
SELECT 1 FROM asset_assignments aa SELECT 1 FROM asset_assignments aa
WHERE aa.asset_id = a.id AND aa.released_at IS NULL WHERE aa.asset_id = a.id AND aa.released_at IS NULL
) )
`); `, [companyId || null, companyId || null]);
return { ...stats, unassigned_assets: unassigned.count }; return { ...stats, unassigned_assets: unassigned.count };
} }

View File

@@ -19,35 +19,39 @@ function bookFromRow(row) {
}; };
} }
function listBooks() { function listBooks(companyId) {
const books = all( const books = all(
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version, `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 FROM books b
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id 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); ).map(bookFromRow);
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name') 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) })); .map((row) => ({ ...row, active: Boolean(row.active) }));
return { books, ruleSets }; return { books, ruleSets };
} }
function getBook(code) { function getBook(code, companyId) {
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null; 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); 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 (!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 }); throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
} }
const ts = now(); 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( 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) `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, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
[ [
companyId,
code, code,
body.name || code, body.name || code,
body.description || null, body.description || null,
@@ -65,20 +69,20 @@ function createBook(body, userId) {
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid])); return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
} }
function deleteBook(id, userId) { function deleteBook(id, userId, companyId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]); 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) return false;
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 }); 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 }); 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 }); 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]); run('DELETE FROM books WHERE id = ?', [id]);
audit(userId, 'delete', 'book', id, before, null); audit(userId, 'delete', 'book', id, before, null);
return true; return true;
} }
function updateBook(id, body, userId) { function updateBook(id, body, userId, companyId) {
const before = one('SELECT * FROM books WHERE id = ?', [id]); const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
if (!before) return null; if (!before) return null;
run( run(
`UPDATE books SET `UPDATE books SET
@@ -104,11 +108,11 @@ function updateBook(id, body, userId) {
// General-ledger view of a book: per-asset basis, accumulated depreciation and // 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. // current-year expense rolled up to GL accounts, plus an asset-level detail.
function bookLedger(code, year) { function bookLedger(code, year, companyId) {
const book = getBook(code); const book = getBook(code, companyId);
if (!book) return null; if (!book) return null;
if (book.book_type === 'maintenance') { if (book.book_type === 'maintenance') {
return { ...pmBookLedger(year), book }; return { ...pmBookLedger(year, companyId), book };
} }
const rules = ruleSetForBook(code); const rules = ruleSetForBook(code);
const rows = all( 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.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 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 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`, ORDER BY a.asset_id`,
[code] [code, companyId || null, companyId || null]
); );
const accountMap = new Map(); 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. // Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
function ledgerReport(code, year) { function ledgerReport(code, year, companyId) {
const ledger = bookLedger(code, year); const ledger = bookLedger(code, year, companyId);
if (!ledger) return null; if (!ledger) return null;
return { return {
title: `${ledger.book.name} general ledger — ${year}`, title: `${ledger.book.name} general ledger — ${year}`,

View 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
};

View File

@@ -47,10 +47,11 @@ function normalize(body) {
return input; return input;
} }
function listContacts(query = {}) { function listContacts(query = {}, companyId) {
return all( return all(
`SELECT * FROM contacts `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 status = ?)
AND (? IS NULL OR ( AND (? IS NULL OR (
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
@@ -59,6 +60,7 @@ function listContacts(query = {}) {
)) ))
ORDER BY type, organization, last_name, first_name`, ORDER BY type, organization, last_name, first_name`,
[ [
companyId || null, companyId || null,
query.type || null, query.type || null, query.type || null, query.type || null,
query.status || null, query.status || null, query.status || null, query.status || null,
query.search || null, query.search || null, query.search || null, query.search || null, query.search || null, query.search || null,
@@ -67,51 +69,52 @@ function listContacts(query = {}) {
).map((row) => fromRow(row)); ).map((row) => fromRow(row));
} }
function getContact(id) { function getContact(id, companyId) {
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true); 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 input = normalize(body);
const ts = now(); const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined); const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run( const result = run(
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at) `INSERT INTO contacts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts] [companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
); );
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) }); 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]); 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 }); const input = normalize({ ...before, ...body });
run( run(
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, `UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...COLUMNS.map((c) => input[c] ?? null), now(), id] [...COLUMNS.map((c) => input[c] ?? null), now(), id]
); );
audit(userId, 'update', 'contact', id, before, body); 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]); 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]); run('DELETE FROM contacts WHERE id = ?', [id]);
audit(userId, 'delete', 'contact', id, before, null); audit(userId, 'delete', 'contact', id, before, null);
return true; return true;
} }
function addNote(contactId, bodyText, userId) { function addNote(contactId, bodyText, userId, companyId) {
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null; 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()]); 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 }); 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]); 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]); const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
if (!result.changes) return false; if (!result.changes) return false;
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId }); audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
@@ -152,10 +155,12 @@ function upsertWorkdayContact(worker) {
); );
return existing.id; 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 cols = Object.keys(fields);
const result = run( const result = run(
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, `INSERT INTO contacts (entity_id, ${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => fields[c]), ts, ts, ts] [defaultCompany ? defaultCompany.id : null, ...cols.map((c) => fields[c]), ts, ts, ts]
); );
return result.lastInsertRowid; return result.lastInsertRowid;
} }

View File

@@ -25,8 +25,8 @@ async function rowsFromImport(format, buffer) {
return Papa.parse(text, { header: true, skipEmptyLines: true }).data; return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
} }
async function assetExport(format) { async function assetExport(format, companyId) {
const rows = assetExportRows(); const rows = assetExportRows(companyId);
if (format === 'csv') { if (format === 'csv') {
return { return {
contentType: 'text/csv', contentType: 'text/csv',

View File

@@ -100,7 +100,7 @@ function decorate(row, totals) {
}; };
} }
function listParts(query = {}) { function listParts(query = {}, companyId) {
const rows = all( const rows = all(
`SELECT p.*, `SELECT p.*,
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last, 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 SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
FROM part_stock GROUP BY part_id FROM part_stock GROUP BY part_id
) st ON st.part_id = p.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.category = ?)
AND (? IS NULL OR ( AND (? IS NULL OR (
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
@@ -120,6 +121,7 @@ function listParts(query = {}) {
)) ))
ORDER BY p.name`, ORDER BY p.name`,
[ [
companyId || null, companyId || null,
query.status || null, query.status || null, query.status || null, query.status || null,
query.category || null, query.category || null, query.category || null, query.category || null,
query.search || null, query.search || null, query.search || null, query.search || null, query.search || null, query.search || null,
@@ -134,11 +136,11 @@ function listParts(query = {}) {
return rows; return rows;
} }
function getPart(id) { function getPart(id, companyId) {
const row = one( 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 `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 = ?`, 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] [id, companyId || null, companyId || null]
); );
if (!row) return null; if (!row) return null;
const stock = stockRows(id); const stock = stockRows(id);
@@ -163,28 +165,28 @@ function normalize(body) {
return input; return input;
} }
function createPart(body, userId) { function createPart(body, userId, companyId) {
const input = normalize(body); const input = normalize(body);
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 }); 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 }); throw Object.assign(new Error('That part number already exists'), { status: 400 });
} }
const ts = now(); const ts = now();
const cols = COLUMNS.filter((c) => input[c] !== undefined); const cols = COLUMNS.filter((c) => input[c] !== undefined);
const result = run( const result = run(
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at) `INSERT INTO parts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`, VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
[...cols.map((c) => input[c]), userId, ts, ts] [companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
); );
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number }); 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]); 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 }); 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 }); throw Object.assign(new Error('That part number already exists'), { status: 400 });
} }
run( run(
@@ -192,12 +194,12 @@ function updatePart(id, body, userId) {
[...COLUMNS.map((c) => input[c] ?? null), now(), id] [...COLUMNS.map((c) => input[c] ?? null), now(), id]
); );
audit(userId, 'update', 'part', id, before, body); 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]); 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]); run('DELETE FROM parts WHERE id = ?', [id]);
audit(userId, 'delete', 'part', id, before, null); audit(userId, 'delete', 'part', id, before, null);
return true; return true;
@@ -205,8 +207,12 @@ function deletePart(id, userId) {
// ---- Stock locations ------------------------------------------------------- // ---- Stock locations -------------------------------------------------------
function saveStock(partId, body, userId) { function partInCompany(partId, companyId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; 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 ts = now();
const location = body.location_contact_id || null; const location = body.location_contact_id || null;
const aisle = body.aisle || 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 }); 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]); const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
if (!result.changes) return null; if (!result.changes) return null;
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null); audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
return getPart(partId); return getPart(partId, companyId);
} }
// ---- Stock movements ------------------------------------------------------- // ---- Stock movements -------------------------------------------------------
function recordTransaction(partId, body, userId) { function recordTransaction(partId, body, userId, companyId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; if (!partInCompany(partId, companyId)) return null;
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive'; 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]); 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 }); 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 }); audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
return getPart(partId); return getPart(partId, companyId);
}); });
} }
// ---- Photos ---------------------------------------------------------------- // ---- Photos ----------------------------------------------------------------
function addPhoto(partId, body, userId) { function addPhoto(partId, body, userId, companyId) {
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null; if (!partInCompany(partId, companyId)) return null;
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 }); if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
run( run(
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', '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()] [partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
); );
audit(userId, 'create', 'part_photo', partId, null, { name: body.name }); 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]); const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
if (!result.changes) return null; if (!result.changes) return null;
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null); audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
return getPart(partId); return getPart(partId, companyId);
} }
module.exports = { module.exports = {

View File

@@ -99,8 +99,9 @@ function planFromRow(row, { withPhotoData = true } = {}) {
}; };
} }
function listPlans() { function listPlans(companyId) {
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false })); 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) { function getPlan(id) {
@@ -162,13 +163,14 @@ function resolveEstimatedMinutes(body, fallback) {
return fallback ?? null; return fallback ?? null;
} }
function createPlan(body, userId) { function createPlan(body, userId, companyId) {
const ts = now(); const ts = now();
return tx(() => { return tx(() => {
const result = run( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
companyId || null,
body.name || 'PM plan', body.description || null, body.category || 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', 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, 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. // 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( const rows = all(
`SELECT c.cost, c.completed_at, a.asset_id, a.description `SELECT c.cost, c.completed_at, a.asset_id, a.description
FROM pm_completions c FROM pm_completions c
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id 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 = {}; const byAsset = {};
let total = 0; let total = 0;

View File

@@ -4,7 +4,7 @@ const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
const { assetExportRows, assetFromRow } = require('./assets'); const { assetExportRows, assetFromRow } = require('./assets');
const { ruleSetForBook } = require('./ruleSets'); const { ruleSetForBook } = require('./ruleSets');
function depreciationReport(year, bookType) { function depreciationReport(year, bookType, companyId) {
const rules = ruleSetForBook(bookType); const rules = ruleSetForBook(bookType);
const rows = all(` const rows = all(`
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life, 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 FROM assets a
JOIN asset_books b ON b.asset_id = a.id JOIN asset_books b ON b.asset_id = a.id
LEFT JOIN entities e ON e.id = a.entity_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 ORDER BY a.asset_id
`, [bookType]); `, [bookType, companyId || null, companyId || null]);
const reportRows = rows.map((row) => { const reportRows = rows.map((row) => {
const asset = assetFromRow(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 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 rows = assets.flatMap((asset) => {
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]); const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
if (!book) return []; if (!book) return [];
@@ -72,8 +72,8 @@ function monthlyDepreciationReport(year, bookType) {
return { year, book: bookType, months }; return { year, book: bookType, months };
} }
function writeDepreciationPdf(res, year, book) { function writeDepreciationPdf(res, year, book, companyId) {
const rows = assetExportRows(); const rows = assetExportRows(companyId);
const doc = new PDFDocument({ margin: 36, size: 'LETTER' }); const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`); res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);

View File

@@ -1630,6 +1630,142 @@ async function main() {
const csvText = await csvResp.text(); const csvText = await csvResp.text();
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed'); 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'); console.log('Smoke test passed');
} }

View File

@@ -72,6 +72,9 @@
:theme-items="themeItems" :theme-items="themeItems"
:title="currentTitle" :title="currentTitle"
:user="user" :user="user"
:companies="entities"
:active-company-id="activeCompanyId"
@change-company="changeCompany"
@change-password="changePasswordDialog = true" @change-password="changePasswordDialog = true"
@logout="logout" @logout="logout"
@open-help="helpDialog = true" @open-help="helpDialog = true"
@@ -83,7 +86,7 @@
</template> </template>
</TopNav> </TopNav>
<v-main id="main-content" tabindex="-1"> <v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
<DashboardView <DashboardView
v-if="activeView === 'dashboard'" v-if="activeView === 'dashboard'"
:category-chart="categoryChart" :category-chart="categoryChart"
@@ -185,12 +188,14 @@
:team-saving="teamSaving" :team-saving="teamSaving"
:team-notice="teamNotice" :team-notice="teamNotice"
:teams="teams" :teams="teams"
:companies="entities"
:user-saving="userSaving" :user-saving="userSaving"
:users="users" :users="users"
:workday-connection="workdayConnection" :workday-connection="workdayConnection"
:workday-saving="workdaySaving" :workday-saving="workdaySaving"
:workday-syncing="workdaySyncing" :workday-syncing="workdaySyncing"
@categories-updated="loadReferences" @categories-updated="loadReferences"
@companies-updated="loadCompanies"
@create-team="createTeam" @create-team="createTeam"
@update-team="updateTeam" @update-team="updateTeam"
@delete-team="deleteTeam" @delete-team="deleteTeam"
@@ -318,7 +323,7 @@ import MassEditDialog from './components/MassEditDialog.vue';
import PmCompletionDialog from './components/PmCompletionDialog.vue'; import PmCompletionDialog from './components/PmCompletionDialog.vue';
import TopNav from './components/TopNav.vue'; import TopNav from './components/TopNav.vue';
import UserManual from './components/UserManual.vue'; import UserManual from './components/UserManual.vue';
import { apiRequest, loginRequest, saveBlob } from './services/api'; import { apiRequest, loginRequest, saveBlob, setActiveCompany } from './services/api';
import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets'; import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets';
import { currency, shortDate } from './utils/format'; import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants'; import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
@@ -393,6 +398,7 @@ export default {
selectedLeaseId: null, selectedLeaseId: null,
employees: [], employees: [],
entities: [], entities: [],
activeCompanyId: null,
error: '', error: '',
helpDialog: false, helpDialog: false,
changePasswordDialog: false, changePasswordDialog: false,
@@ -554,6 +560,9 @@ export default {
entityOptions() { entityOptions() {
return this.entities.map((entity) => ({ title: entity.name, value: entity.id })); return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
}, },
activeCompany() {
return this.entities.find((entity) => entity.id === this.activeCompanyId) || null;
},
activeBookItems() { activeBookItems() {
return this.bookCodes.length ? this.bookCodes : this.bookItems; return this.bookCodes.length ? this.bookCodes : this.bookItems;
}, },
@@ -1093,12 +1102,13 @@ export default {
this.dashboard = await (await this.api('/api/dashboard')).json(); this.dashboard = await (await this.api('/api/dashboard')).json();
}, },
async loadInitial() { async loadInitial() {
// Resolve the active company first so every other request is scoped to it.
await this.loadCompanies();
await Promise.all([ await Promise.all([
this.loadProfile(), this.loadProfile(),
this.loadDashboard(), this.loadDashboard(),
this.loadAssets(), this.loadAssets(),
this.loadAssignments(), this.loadAssignments(),
this.api('/api/entities').then((r) => r.json()).then((data) => { this.entities = data.entities; }),
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }), this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }), this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }), this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }),
@@ -1108,6 +1118,24 @@ export default {
this.loadDepreciationZones() this.loadDepreciationZones()
]); ]);
}, },
// Load the active companies and pick the active one (stored choice if still valid, else the first).
async loadCompanies() {
const data = await (await this.api('/api/entities')).json();
this.entities = data.entities || [];
const stored = Number(localStorage.getItem('deprecore.activeCompany'));
const valid = this.entities.find((e) => e.id === stored);
this.activeCompanyId = valid ? valid.id : (this.entities[0]?.id || null);
localStorage.setItem('deprecore.activeCompany', String(this.activeCompanyId || ''));
setActiveCompany(this.activeCompanyId);
},
// Switch the active company: persist it, update the request header, and reload all scoped data.
async changeCompany(id) {
if (!id || id === this.activeCompanyId) return;
this.activeCompanyId = id;
localStorage.setItem('deprecore.activeCompany', String(id));
setActiveCompany(id);
await this.loadInitial();
},
async loadAssignments() { async loadAssignments() {
const [employees, assignments, workday] = await Promise.all([ const [employees, assignments, workday] = await Promise.all([
this.api('/api/employees').then((r) => r.json()), this.api('/api/employees').then((r) => r.json()),
@@ -1210,7 +1238,7 @@ export default {
async newAsset() { async newAsset() {
await this.loadBookCodes(); await this.loadBookCodes();
this.assetForm = blankAsset(); this.assetForm = blankAsset();
this.assetForm.entity_id = this.entities[0]?.id || null; this.assetForm.entity_id = this.activeCompanyId || this.entities[0]?.id || null;
this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes); this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes);
this.selectedTemplateId = null; this.selectedTemplateId = null;
this.drawerError = ''; this.drawerError = '';

View File

@@ -0,0 +1,194 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Companies</h2>
<p class="quiet text-body-2">
Each company keeps its own assets, books, general ledger, PM plans, and alerts. Switch the active company
from the top bar. Disposing a company archives it (history is preserved) and removes it from the switcher.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-domain-plus" @click="openNew">New company</v-btn>
</div>
<DataTable
:columns="columns"
:rows="companies"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter companies"
empty-text="No companies."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-status="{ row }">
<v-chip size="x-small" :color="row.status === 'active' ? 'success' : 'error'" variant="tonal">{{ row.status }}</v-chip>
</template>
<template #cell-base_currency="{ row }">{{ row.base_currency || 'USD' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="row.status === 'active'" icon="mdi-archive-arrow-down-outline" size="small" variant="text" color="error" title="Dispose / archive" @click="confirmDispose(row)" />
<v-btn v-else icon="mdi-restore" size="small" variant="text" color="success" title="Restore" @click="restore(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="560">
<v-card>
<v-card-title>{{ form.id ? 'Edit company' : 'New company' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" class="full" label="Company name *" autofocus />
<v-text-field v-model="form.legal_name" class="full" label="Legal name" />
<v-text-field v-model="form.tax_id" label="Tax ID / EIN" />
<v-text-field v-model="form.base_currency" label="Base currency" placeholder="USD" />
<v-select v-model.number="form.fiscal_year_end_month" :items="monthItems" item-title="title" item-value="value" label="Fiscal year-end month" />
</div>
<v-alert v-if="!form.id" type="info" variant="tonal" density="compact" class="mt-2">
A new company starts with the standard book set (GAAP, Federal, State, AMT, ACE, User, PM).
</v-alert>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save company</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dispose confirmation -->
<v-dialog v-model="disposeDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Dispose company</v-card-title>
<v-card-text>
<p class="mb-3">Archive <strong>{{ disposeTarget?.name }}</strong>?</p>
<v-alert type="info" variant="tonal" density="comfortable" class="mb-3">
Its assets, books, GL, PM plans, and alerts are <strong>kept</strong> for audit and reporting, but the company
is hidden from the switcher and can no longer be selected for new work. You can restore it later.
</v-alert>
<v-text-field v-model="disposeReason" label="Reason (optional)" density="compact" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="disposeDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-archive-arrow-down-outline" @click="performDispose">Dispose company</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
function blankCompany() {
return { id: null, name: '', legal_name: '', tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
companies: [],
dialog: false,
disposeDialog: false,
disposeTarget: null,
disposeReason: '',
form: blankCompany(),
saving: false,
error: '',
dialogError: '',
monthItems: Array.from({ length: 12 }, (_, i) => ({ title: new Date(2000, i, 1).toLocaleString('en-US', { month: 'long' }), value: i + 1 })),
columns: [
{ key: 'name', label: 'Company' },
{ key: 'legal_name', label: 'Legal name' },
{ key: 'tax_id', label: 'Tax ID' },
{ key: 'base_currency', label: 'Currency' },
{ key: 'status', label: 'Status', sortable: false }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/entities?includeDisposed=1')).json();
this.companies = data.entities;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankCompany();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', tax_id: row.tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 };
this.dialogError = '';
this.dialog = true;
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/entities/${this.form.id}` : '/api/entities';
await this.api(url, { method, body: JSON.stringify(this.form) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDispose(row) {
this.disposeTarget = row;
this.disposeReason = '';
this.dialogError = '';
this.disposeDialog = true;
},
async performDispose() {
this.dialogError = '';
try {
await this.api(`/api/entities/${this.disposeTarget.id}/dispose`, { method: 'POST', body: JSON.stringify({ reason: this.disposeReason || null }) });
this.disposeDialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
}
},
async restore(row) {
this.error = '';
try {
await this.api(`/api/entities/${row.id}/restore`, { method: 'POST', body: JSON.stringify({}) });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>

View File

@@ -6,6 +6,23 @@
<div class="text-h6 font-weight-bold">{{ title }}</div> <div class="text-h6 font-weight-bold">{{ title }}</div>
</div> </div>
<v-spacer /> <v-spacer />
<v-select
v-if="companies.length > 1"
:model-value="activeCompanyId"
:items="companyItems"
item-title="title"
item-value="value"
density="compact"
variant="solo-filled"
flat
hide-details
prepend-inner-icon="mdi-domain"
class="company-switch mr-2"
style="max-width:220px"
aria-label="Active company"
@update:model-value="$emit('change-company', $event)"
/>
<v-chip v-else-if="companies.length === 1" size="small" variant="tonal" prepend-icon="mdi-domain" class="mr-2">{{ companies[0].name }}</v-chip>
<slot name="actions" /> <slot name="actions" />
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" /> <v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end"> <v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
@@ -117,9 +134,11 @@ export default {
subtitle: { type: String, required: true }, subtitle: { type: String, required: true },
themeItems: { type: Array, required: true }, themeItems: { type: Array, required: true },
title: { type: String, required: true }, title: { type: String, required: true },
user: { type: Object, required: true } user: { type: Object, required: true },
companies: { type: Array, default: () => [] },
activeCompanyId: { type: [Number, String], default: null }
}, },
emits: ['change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'], emits: ['change-company', 'change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
data() { data() {
return { return {
accentPresets, accentPresets,
@@ -131,6 +150,9 @@ export default {
}; };
}, },
computed: { computed: {
companyItems() {
return this.companies.map((c) => ({ title: c.name, value: c.id }));
},
initials() { initials() {
return String(this.user?.name || 'MA') return String(this.user?.name || 'MA')
.split(/\s+/) .split(/\s+/)

View File

@@ -1,9 +1,16 @@
// The active company id is sent on every request as X-Company-Id so the server scopes data to it.
let activeCompanyId = null;
export function setActiveCompany(id) {
activeCompanyId = id || null;
}
export async function apiRequest(path, token, options = {}) { export async function apiRequest(path, token, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
...options, ...options,
headers: { headers: {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(activeCompanyId ? { 'X-Company-Id': String(activeCompanyId) } : {}),
...(options.headers || {}) ...(options.headers || {})
} }
}); });

View File

@@ -172,6 +172,8 @@
<!-- APPLICATION CONFIGURATION --> <!-- APPLICATION CONFIGURATION -->
<template v-if="adminSection === 'admin-config'"> <template v-if="adminSection === 'admin-config'">
<CompanySettings :token="token" @updated="$emit('companies-updated')" />
<v-card class="span-6 panel-pad"> <v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Application settings</h2> <h2 class="section-title mb-4">Application settings</h2>
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" /> <v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
@@ -262,6 +264,19 @@
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint /> <v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
</div> </div>
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" /> <v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
<v-select
v-model="userForm.company_ids"
:items="companyItems"
item-title="title"
item-value="value"
label="Company access"
multiple
chips
closable-chips
class="mt-3"
hint="Companies this user can see and switch between. Administrators always see every company."
persistent-hint
/>
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info"> <v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }} {{ roleSummary(userForm.role) }}
</v-alert> </v-alert>
@@ -296,6 +311,7 @@ import AssetClassSettings from '../components/AssetClassSettings.vue';
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue'; import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
import AuditTrailSettings from '../components/AuditTrailSettings.vue'; import AuditTrailSettings from '../components/AuditTrailSettings.vue';
import CategorySettings from '../components/CategorySettings.vue'; import CategorySettings from '../components/CategorySettings.vue';
import CompanySettings from '../components/CompanySettings.vue';
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue'; import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
import DataTable from '../components/DataTable.vue'; import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue'; import DepartmentSettings from '../components/DepartmentSettings.vue';
@@ -310,7 +326,7 @@ import TeamsSettings from '../components/TeamsSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue'; import WebhookSettings from '../components/WebhookSettings.vue';
export default { export default {
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings }, components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
props: { props: {
token: { type: String, default: '' }, token: { type: String, default: '' },
section: { type: String, default: 'admin-users' }, section: { type: String, default: 'admin-users' },
@@ -322,13 +338,14 @@ export default {
teamSaving: { type: Boolean, default: false }, teamSaving: { type: Boolean, default: false },
teams: { type: Array, default: () => [] }, teams: { type: Array, default: () => [] },
teamNotice: { type: String, default: '' }, teamNotice: { type: String, default: '' },
companies: { type: Array, default: () => [] },
userSaving: { type: Boolean, default: false }, userSaving: { type: Boolean, default: false },
users: { type: Array, default: () => [] }, users: { type: Array, default: () => [] },
workdayConnection: { type: Object, default: () => ({}) }, workdayConnection: { type: Object, default: () => ({}) },
workdaySaving: { type: Boolean, default: false }, workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false } workdaySyncing: { type: Boolean, default: false }
}, },
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'], emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated', 'companies-updated'],
data() { data() {
return { return {
localSettings: { ...this.settings }, localSettings: { ...this.settings },
@@ -364,6 +381,9 @@ export default {
teamOptions() { teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id })); return this.teams.map((team) => ({ title: team.name, value: team.id }));
}, },
companyItems() {
return this.companies.map((company) => ({ title: company.name, value: company.id }));
},
roleSelectItems() { roleSelectItems() {
return this.roles.map((role) => ({ title: role.name, value: role.key })); return this.roles.map((role) => ({ title: role.name, value: role.key }));
}, },
@@ -404,7 +424,8 @@ export default {
role: 'viewer', role: 'viewer',
status: 'active', status: 'active',
password: '', password: '',
must_change_password: false must_change_password: false,
company_ids: []
}; };
}, },
editUser(account) { editUser(account) {
@@ -416,7 +437,8 @@ export default {
role: account.role, role: account.role,
status: account.status, status: account.status,
password: '', password: '',
must_change_password: Boolean(account.must_change_password) must_change_password: Boolean(account.must_change_password),
company_ids: Array.isArray(account.company_ids) ? [...account.company_ids] : []
}; };
this.userDialog = true; this.userDialog = true;
}, },