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

@@ -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),

View File

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

View File

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

View File

@@ -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}`,

View File

@@ -0,0 +1,128 @@
const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db');
// Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and
// (in phase 2) reference data. Disposing a company is a soft status change that preserves all history.
function listCompanies({ includeDisposed = false } = {}) {
const where = includeDisposed ? '' : "WHERE status = 'active'";
return all(`SELECT * FROM entities ${where} ORDER BY status, name`);
}
function getCompany(id) {
return one('SELECT * FROM entities WHERE id = ?', [id]);
}
function activeCount() {
return one("SELECT COUNT(*) AS c FROM entities WHERE status = 'active'").c;
}
function createCompany(body, userId) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
const ts = now();
const result = run(
`INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
[name, body.legal_name || null, body.tax_id || null, Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts]
);
// A new company starts with the standard book set + reference data (categories/locations/depts).
seedBooksForCompany(result.lastInsertRowid);
seedReferenceValuesForCompany(result.lastInsertRowid);
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
return getCompany(result.lastInsertRowid);
}
function updateCompany(id, body, userId) {
const before = getCompany(id);
if (!before) return null;
run(
`UPDATE entities SET name = ?, legal_name = ?, tax_id = ?, fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
[
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
body.legal_name !== undefined ? body.legal_name : before.legal_name,
body.tax_id !== undefined ? body.tax_id : before.tax_id,
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
body.base_currency !== undefined ? body.base_currency : before.base_currency,
now(), id
]
);
audit(userId, 'update', 'entity', id, before, body);
return getCompany(id);
}
// Archive/retire a company: it stays in the database (assets, books, GL preserved) but is hidden from
// active selection and can no longer be the active company. Guards the last active company.
function disposeCompany(id, body, userId) {
const before = getCompany(id);
if (!before) return { ok: false, status: 404, error: 'Company not found' };
if (before.status === 'disposed') return { ok: false, status: 400, error: 'Company is already disposed' };
if (activeCount() <= 1) return { ok: false, status: 400, error: 'At least one active company is required' };
run("UPDATE entities SET status = 'disposed', disposed_at = ?, disposed_reason = ?, updated_at = ? WHERE id = ?",
[now(), body.reason || null, now(), id]);
audit(userId, 'dispose', 'entity', id, before, { reason: body.reason || null });
return { ok: true, company: getCompany(id) };
}
function restoreCompany(id, userId) {
const before = getCompany(id);
if (!before) return null;
run("UPDATE entities SET status = 'active', disposed_at = NULL, disposed_reason = NULL, updated_at = ? WHERE id = ?", [now(), id]);
audit(userId, 'restore', 'entity', id, before, null);
return getCompany(id);
}
// ---- Per-company user access ------------------------------------------------
// A user who can manage users (admin.users / wildcard) sees every company; this prevents an admin
// from locking themselves out of any company. Required lazily to avoid a load-time cycle.
function isCompanyAdmin(user) {
const { capabilitiesForRole } = require('./roles');
const { capabilitySetIncludes } = require('./accessControl');
return capabilitySetIncludes(capabilitiesForRole(user.role), 'admin.users');
}
// Active companies a user may see/switch to. Admins → all; others → their assigned companies, falling
// back to the default company when they have none (so a user is never locked out).
function companiesForUser(user) {
if (isCompanyAdmin(user)) {
return all("SELECT * FROM entities WHERE status = 'active' ORDER BY name");
}
const rows = all(
`SELECT e.* FROM entities e JOIN user_companies uc ON uc.entity_id = e.id
WHERE uc.user_id = ? AND e.status = 'active' ORDER BY e.name`,
[user.id]
);
if (rows.length) return rows;
const def = one("SELECT * FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
return def ? [def] : [];
}
function companyIdsForUser(user) {
return companiesForUser(user).map((c) => c.id);
}
// Explicit assignments stored for a user (independent of the admin bypass).
function assignedCompanyIds(userId) {
return all('SELECT entity_id FROM user_companies WHERE user_id = ?', [userId]).map((r) => r.entity_id);
}
function setUserCompanies(userId, ids, actorId) {
const valid = (Array.isArray(ids) ? ids : []).filter((id) => one('SELECT id FROM entities WHERE id = ?', [id]));
run('DELETE FROM user_companies WHERE user_id = ?', [userId]);
for (const id of valid) run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) VALUES (?, ?)', [userId, id]);
if (actorId) audit(actorId, 'set_companies', 'user', userId, null, { company_ids: valid });
}
module.exports = {
assignedCompanyIds,
companiesForUser,
companyIdsForUser,
createCompany,
disposeCompany,
getCompany,
isCompanyAdmin,
listCompanies,
restoreCompany,
setUserCompanies,
updateCompany
};

View File

@@ -47,10 +47,11 @@ function normalize(body) {
return input;
}
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;
}

View File

@@ -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',

View File

@@ -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 = {

View File

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

View File

@@ -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"`);