diff --git a/server/db.js b/server/db.js index c1c506f..d41d4ad 100644 --- a/server/db.js +++ b/server/db.js @@ -119,6 +119,9 @@ function initialize() { name TEXT NOT NULL, legal_name TEXT, tax_id TEXT, + federal_tax_id TEXT, + state_tax_id TEXT, + local_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', @@ -140,6 +143,7 @@ function initialize() { CREATE TABLE IF NOT EXISTS employees ( id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER REFERENCES entities(id), workday_worker_id TEXT UNIQUE, employee_number TEXT, name TEXT NOT NULL, @@ -781,12 +785,18 @@ function migrate() { ensureColumn('entities', 'status', "TEXT NOT NULL DEFAULT 'active'"); ensureColumn('entities', 'disposed_at', 'TEXT'); ensureColumn('entities', 'disposed_reason', 'TEXT'); + // Separate federal / state / optional local tax ids; the legacy single tax_id becomes the federal one. + ensureColumn('entities', 'federal_tax_id', 'TEXT'); + ensureColumn('entities', 'state_tax_id', 'TEXT'); + ensureColumn('entities', 'local_tax_id', 'TEXT'); + run('UPDATE entities SET federal_tax_id = tax_id WHERE federal_tax_id IS NULL AND tax_id IS NOT NULL'); 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)'); + ensureColumn('employees', 'entity_id', 'INTEGER REFERENCES entities(id)'); rebuildReferenceValuesPerCompany(); rebuildAssetTemplatesPerCompany(); rebuildPartsPerCompany(); @@ -917,7 +927,7 @@ function backfillCompanyScope() { 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']) { + for (const table of ['assets', 'books', 'pm_plans', 'alerts', 'reference_values', 'contacts', 'asset_templates', 'saved_reports', 'parts', 'employees']) { 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, @@ -1142,9 +1152,9 @@ function seed() { run( `INSERT OR IGNORE INTO employees ( - employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'DepreCore Admin', 'active', 'seed', createdAt, createdAt] + entity_id, employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [refEntity ? refEntity.id : null, 'E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'DepreCore Admin', 'active', 'seed', createdAt, createdAt] ); run( diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 59e30f6..c11c62e 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -12,11 +12,11 @@ const { const router = express.Router(); router.get('/employees', (req, res) => { - res.json({ employees: listEmployees(req.query) }); + res.json({ employees: listEmployees(req.query, req.companyId) }); }); router.post('/employees', requireCapability('assets.assign'), (req, res) => { - res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) }); + res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }, req.companyId) }); }); router.get('/assignments', (req, res) => { diff --git a/server/services/assignments.js b/server/services/assignments.js index 3b85964..364b597 100644 --- a/server/services/assignments.js +++ b/server/services/assignments.js @@ -16,15 +16,17 @@ function assignmentFromRow(row) { }; } -function listEmployees(query = {}) { +function listEmployees(query = {}, companyId) { return all(` SELECT * FROM employees - WHERE (? IS NULL OR status = ?) + WHERE (? IS NULL OR entity_id = ?) + AND (? IS NULL OR status = ?) AND (? IS NULL OR name LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR employee_number LIKE '%' || ? || '%') ORDER BY name LIMIT 500 `, [ + companyId || null, companyId || null, query.status || null, query.status || null, query.search || null, @@ -34,12 +36,14 @@ function listEmployees(query = {}) { ]).map(employeeFromRow); } -function upsertEmployee(employee) { +// Manual creates use the active company; Workday sync (no companyId) lands in the default company. +function upsertEmployee(employee, companyId) { const timestamp = now(); + const entityId = companyId || (one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1")?.id) || null; const workdayId = employee.workday_worker_id || employee.workdayWorkerId || employee.workerId || null; const existing = workdayId ? one('SELECT id FROM employees WHERE workday_worker_id = ?', [workdayId]) - : one('SELECT id FROM employees WHERE employee_number = ? OR lower(email) = lower(?)', [employee.employee_number || '', employee.email || '']); + : one('SELECT id FROM employees WHERE entity_id = ? AND (employee_number = ? OR lower(email) = lower(?))', [entityId, employee.employee_number || '', employee.email || '']); const values = [ workdayId, @@ -79,10 +83,10 @@ function upsertEmployee(employee) { const result = run( `INSERT INTO employees ( - workday_worker_id, employee_number, name, email, department, location, manager_name, + entity_id, workday_worker_id, employee_number, name, email, department, location, manager_name, status, source, source_payload, last_synced_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [...values, timestamp] + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [entityId, ...values, timestamp] ); return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]); } @@ -116,7 +120,8 @@ function assignAsset(body, userId, companyId) { 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; + // Only an employee in the same company may be assigned (foreign ids resolve to no employee). + const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.employee_id, companyId || null, companyId || null]) : null; const department = body.department ?? employee?.department ?? asset.department ?? null; const location = body.location ?? employee?.location ?? asset.location ?? null; @@ -134,7 +139,7 @@ function assignAsset(body, userId, companyId) { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ body.asset_id, - body.employee_id || null, + employee ? employee.id : null, department, location, 'assigned', diff --git a/server/services/companies.js b/server/services/companies.js index 375c838..3f44234 100644 --- a/server/services/companies.js +++ b/server/services/companies.js @@ -21,9 +21,13 @@ function createCompany(body, userId) { 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] + `INSERT INTO entities (name, legal_name, federal_tax_id, state_tax_id, local_tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, + [ + name, body.legal_name || null, + body.federal_tax_id || null, body.state_tax_id || null, body.local_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); @@ -36,11 +40,14 @@ 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 = ?`, + `UPDATE entities SET name = ?, legal_name = ?, federal_tax_id = ?, state_tax_id = ?, local_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.federal_tax_id !== undefined ? body.federal_tax_id : before.federal_tax_id, + body.state_tax_id !== undefined ? body.state_tax_id : before.state_tax_id, + body.local_tax_id !== undefined ? body.local_tax_id : before.local_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 diff --git a/server/smoke-test.js b/server/smoke-test.js index 3e07a7c..b41fbd4 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1634,10 +1634,15 @@ async function main() { 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; + const companyBEntity = (await request('/api/entities', { + method: 'POST', headers: auth, + body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD', federal_tax_id: '12-3456789', state_tax_id: 'ST-001', local_tax_id: 'LOC-9' }) + })).entity; + const companyB = companyBEntity.id; if (!companyB || companyB === companyA) throw new Error('Second company was not created'); + if (companyBEntity.federal_tax_id !== '12-3456789' || companyBEntity.state_tax_id !== 'ST-001' || companyBEntity.local_tax_id !== 'LOC-9') { + throw new Error('Company federal/state/local tax ids did not persist'); + } // 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; @@ -1766,6 +1771,19 @@ async function main() { }); if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404'); + // The employee directory is per-company. + const empB = (await request('/api/employees', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: 'B Worker', employee_number: `EMP-${suffix}` }) })).employee; + if (!empB || empB.entity_id !== companyB) throw new Error('New employee was not scoped to the active company'); + const empsA = (await request('/api/employees', { headers: companyHeaders(companyA) })).employees; + const empsB = (await request('/api/employees', { headers: companyHeaders(companyB) })).employees; + if (empsA.some((e) => e.id === empB.id)) throw new Error('Company A sees company B employee'); + if (!empsB.some((e) => e.id === empB.id)) throw new Error('Company B employee missing'); + // Company B's employee cannot be attached to a company A assignment (resolves to no employee). + const crossEmpAssign = (await request('/api/assignments', { + method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ asset_id: assetAonly.id, employee_id: empB.id }) + })).assignment; + if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee'); + console.log('Smoke test passed'); } diff --git a/src/components/CompanySettings.vue b/src/components/CompanySettings.vue index bf667b9..d0e596b 100644 --- a/src/components/CompanySettings.vue +++ b/src/components/CompanySettings.vue @@ -45,7 +45,9 @@
- + + +
@@ -90,7 +92,7 @@ import DataTable from './DataTable.vue'; import { apiRequest } from '../services/api'; function blankCompany() { - return { id: null, name: '', legal_name: '', tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 }; + return { id: null, name: '', legal_name: '', federal_tax_id: '', state_tax_id: '', local_tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 }; } export default { @@ -114,7 +116,7 @@ export default { columns: [ { key: 'name', label: 'Company' }, { key: 'legal_name', label: 'Legal name' }, - { key: 'tax_id', label: 'Tax ID' }, + { key: 'federal_tax_id', label: 'Federal tax ID' }, { key: 'base_currency', label: 'Currency' }, { key: 'status', label: 'Status', sortable: false } ] @@ -142,7 +144,7 @@ export default { this.dialog = true; }, openEdit(row) { - this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', tax_id: row.tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 }; + this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', federal_tax_id: row.federal_tax_id || '', state_tax_id: row.state_tax_id || '', local_tax_id: row.local_tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 }; this.dialogError = ''; this.dialog = true; },