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

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