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