Added Multi-Company Support

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

View File

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