From 02726018a7ee798d6396c9259ad3a6c413c1032c Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sun, 17 May 2026 19:01:07 -0500 Subject: [PATCH] feat(models): Sequelize associations across the full entity graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up hasMany / belongsTo for every FK in the schema, centralized in db.config.js so the relationship graph is visible in one file rather than scattered across 18 model definitions. JS-side only — the DB-level FK constraints remain authoritative via setup/*.sql + the migrations; associations here enable Sequelize \`include\` (eager loading) and the auto-generated getter/setter methods. Graph shape: - Company is the tenancy root: hasMany Customer, Worker, BillingType, InventoryItem, ApiKey, TimeEntry, PurchaseOrderVendor, InventoryTransaction - Customer fans out: TimeEntry, Job, Invoice, CustomerPayment - Job fans out: InvoiceJob, ProductEntry - Invoice fans out: InvoiceJob (lines) - InventoryItem fans in: ProductEntry, InventoryTransaction, PurchaseOrderLine - PO chain: Vendor → Header → Line A handful of FK column names are unusual ("polpoh" lowercase, "invtInitId" with the BACPAC "init" prefix, "penArch" typo we match rather than rename) — associations use the actual column names so eager loads work without aliasing surprises. Verified by a new unit-test suite (tests/unit/associations.test.js) that walks Model.associations and asserts each expected edge. Catches typos and column-name drift early. Tests: 29 files / 223 (was 28 / 199). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/db.config.js | 75 ++++++++++++++++++++++++ tests/unit/associations.test.js | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/unit/associations.test.js diff --git a/app/config/db.config.js b/app/config/db.config.js index 1a42dc7..d55d9f0 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -38,4 +38,79 @@ db.PurchaseOrderHeader = require('../models/purchaseorderheader.model.js')(seque db.PurchaseOrderLine = require('../models/purchaseorderline.model.js')(sequelize, Sequelize); db.InventoryTransaction = require('../models/inventorytransaction.model.js')(sequelize, Sequelize); +// ---------------------------------------------------------------------- +// Associations — centralized so the relationship graph is visible +// in one place rather than scattered across 15 model files. +// +// These are JS-side associations only; they do NOT add FK constraints +// to the live schema. The DDL in setup/TimeTracker.sql + the +// 20260517000000 migration are authoritative for what the database +// physically enforces. Associations here enable Sequelize `include` +// (eager loading) and the auto-generated getter/setter methods, no +// more. +// +// FK column names match the existing schema column names verbatim — +// note that several use unusual casings ("polpoh", "invtInitId", +// "penArch" typo) that we mirror rather than rename. +// ---------------------------------------------------------------------- + +// Company is the root of the tenancy tree — most entities belongsTo it. +db.Company.hasMany(db.Customer, { foreignKey: 'custCompId', as: 'customers' }); +db.Company.hasMany(db.Worker, { foreignKey: 'workerCompId', as: 'workers' }); +db.Company.hasMany(db.BillingType, { foreignKey: 'btCompId', as: 'billingTypes' }); +db.Company.hasMany(db.InventoryItem, { foreignKey: 'invitCompId', as: 'inventoryItems' }); +db.Company.hasMany(db.ApiKey, { foreignKey: 'akCompanyId', as: 'apiKeys' }); +db.Company.hasMany(db.TimeEntry, { foreignKey: 'teCompId', as: 'timeEntries' }); +db.Company.hasMany(db.PurchaseOrderVendor, { foreignKey: 'povCompId', as: 'purchaseOrderVendors' }); +db.Company.hasMany(db.InventoryTransaction, { foreignKey: 'invtCompanyId', as: 'inventoryTransactions' }); + +db.Customer.belongsTo(db.Company, { foreignKey: 'custCompId', as: 'company' }); +db.Worker.belongsTo(db.Company, { foreignKey: 'workerCompId', as: 'company' }); +db.BillingType.belongsTo(db.Company, { foreignKey: 'btCompId', as: 'company' }); +db.InventoryItem.belongsTo(db.Company, { foreignKey: 'invitCompId', as: 'company' }); +db.ApiKey.belongsTo(db.Company, { foreignKey: 'akCompanyId', as: 'company' }); +db.TimeEntry.belongsTo(db.Company, { foreignKey: 'teCompId', as: 'company' }); +db.PurchaseOrderVendor.belongsTo(db.Company,{ foreignKey: 'povCompId', as: 'company' }); +db.InventoryTransaction.belongsTo(db.Company,{ foreignKey: 'invtCompanyId', as: 'company' }); + +// Customer fans out to job-like and ledger entities. +db.Customer.hasMany(db.TimeEntry, { foreignKey: 'teCustId', as: 'timeEntries' }); +db.Customer.hasMany(db.Job, { foreignKey: 'jobCustId', as: 'jobs' }); +db.Customer.hasMany(db.Invoice, { foreignKey: 'invCustId', as: 'invoices' }); +db.Customer.hasMany(db.CustomerPayment, { foreignKey: 'cpayCustId', as: 'payments' }); + +db.TimeEntry.belongsTo(db.Customer, { foreignKey: 'teCustId', as: 'customer' }); +db.Job.belongsTo(db.Customer, { foreignKey: 'jobCustId', as: 'customer' }); +db.Invoice.belongsTo(db.Customer, { foreignKey: 'invCustId', as: 'customer' }); +db.CustomerPayment.belongsTo(db.Customer, { foreignKey: 'cpayCustId', as: 'customer' }); + +// Worker → BillingType (default rate). +db.BillingType.hasMany(db.Worker, { foreignKey: 'workerDefaultBillType', as: 'workersWithDefault' }); +db.Worker.belongsTo(db.BillingType, { foreignKey: 'workerDefaultBillType', as: 'defaultBillingType' }); + +// Job has lines (InvoiceJob) and product entries. +db.Job.hasMany(db.InvoiceJob, { foreignKey: 'injbJobId', as: 'invoiceLines' }); +db.Job.hasMany(db.ProductEntry, { foreignKey: 'pentJobId', as: 'productEntries' }); +db.InvoiceJob.belongsTo(db.Job, { foreignKey: 'injbJobId', as: 'job' }); +db.ProductEntry.belongsTo(db.Job, { foreignKey: 'pentJobId', as: 'job' }); + +// Invoice has its line items. +db.Invoice.hasMany(db.InvoiceJob, { foreignKey: 'injbInvId', as: 'lines' }); +db.InvoiceJob.belongsTo(db.Invoice,{ foreignKey: 'injbInvId', as: 'invoice' }); + +// InventoryItem is referenced by product entries, inventory +// transactions, and PO lines. +db.InventoryItem.hasMany(db.ProductEntry, { foreignKey: 'pentInvtId', as: 'productEntries' }); +db.InventoryItem.hasMany(db.InventoryTransaction, { foreignKey: 'invtInitId', as: 'transactions' }); +db.InventoryItem.hasMany(db.PurchaseOrderLine, { foreignKey: 'polInvtId', as: 'purchaseOrderLines' }); +db.ProductEntry.belongsTo(db.InventoryItem, { foreignKey: 'pentInvtId', as: 'inventoryItem' }); +db.InventoryTransaction.belongsTo(db.InventoryItem,{ foreignKey: 'invtInitId', as: 'inventoryItem' }); +db.PurchaseOrderLine.belongsTo(db.InventoryItem, { foreignKey: 'polInvtId', as: 'inventoryItem' }); + +// PurchaseOrder chain: Vendor → Header → Line. +db.PurchaseOrderVendor.hasMany(db.PurchaseOrderHeader, { foreignKey: 'pohPovId', as: 'purchaseOrders' }); +db.PurchaseOrderHeader.belongsTo(db.PurchaseOrderVendor,{ foreignKey: 'pohPovId', as: 'vendor' }); +db.PurchaseOrderHeader.hasMany(db.PurchaseOrderLine, { foreignKey: 'polpoh', as: 'lines' }); +db.PurchaseOrderLine.belongsTo(db.PurchaseOrderHeader, { foreignKey: 'polpoh', as: 'header' }); + module.exports = db; diff --git a/tests/unit/associations.test.js b/tests/unit/associations.test.js new file mode 100644 index 0000000..a41c19d --- /dev/null +++ b/tests/unit/associations.test.js @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Verifies the Sequelize association graph wired up in db.config.js. +// We don't hit the DB — we just walk Model.associations and assert +// each expected belongsTo / hasMany is present with the correct +// foreign-key column. This catches typos and column-name drift early. + +import { describe, test, expect } from 'vitest'; + +// Import db.config.js indirectly to avoid the env-var warning chatter +// that the api tests' mock side-steps. We don't actually open a +// connection here; Sequelize doesn't connect until a query runs. +const db = require('../../app/config/db.config.js'); + +function assertAssoc(modelName, kind, fkColumn, targetName) { + const model = db[modelName]; + expect(model, `${modelName} should be defined on db`).toBeDefined(); + const assocs = Object.values(model.associations || {}); + const match = assocs.find(a => + a.associationType === kind && + a.foreignKey === fkColumn && + a.target.name === targetName, + ); + expect(match, `${modelName} should ${kind} ${targetName} via ${fkColumn}`).toBeDefined(); +} + +describe('association graph: Company is the tenancy root', () => { + test.each([ + ['Customer', 'custCompId'], + ['Worker', 'workerCompId'], + ['BillingType', 'btCompId'], + ['InventoryItem', 'invitCompId'], + ['ApiKey', 'akCompanyId'], + ['TimeEntry', 'teCompId'], + ['PurchaseOrderVendor', 'povCompId'], + ['InventoryTransaction', 'invtCompanyId'], + ])('%s belongsTo Company via %s', (entity, fk) => { + assertAssoc(entity, 'BelongsTo', fk, 'Company'); + }); + + test.each([ + ['Customer', 'custCompId'], + ['Worker', 'workerCompId'], + ['ApiKey', 'akCompanyId'], + ])('Company hasMany %s via %s', (entity, fk) => { + assertAssoc('Company', 'HasMany', fk, entity); + }); +}); + +describe('association graph: Customer fan-out', () => { + test.each([ + ['TimeEntry', 'teCustId'], + ['Job', 'jobCustId'], + ['Invoice', 'invCustId'], + ['CustomerPayment', 'cpayCustId'], + ])('%s belongsTo Customer via %s', (entity, fk) => { + assertAssoc(entity, 'BelongsTo', fk, 'Customer'); + }); +}); + +describe('association graph: Job line items', () => { + test('InvoiceJob belongsTo Job via injbJobId', () => { + assertAssoc('InvoiceJob', 'BelongsTo', 'injbJobId', 'Job'); + }); + test('InvoiceJob belongsTo Invoice via injbInvId', () => { + assertAssoc('InvoiceJob', 'BelongsTo', 'injbInvId', 'Invoice'); + }); + test('ProductEntry belongsTo Job via pentJobId', () => { + assertAssoc('ProductEntry', 'BelongsTo', 'pentJobId', 'Job'); + }); +}); + +describe('association graph: InventoryItem fan-in', () => { + test('ProductEntry belongsTo InventoryItem via pentInvtId', () => { + assertAssoc('ProductEntry', 'BelongsTo', 'pentInvtId', 'InventoryItem'); + }); + test('InventoryTransaction belongsTo InventoryItem via invtInitId', () => { + assertAssoc('InventoryTransaction', 'BelongsTo', 'invtInitId', 'InventoryItem'); + }); + test('PurchaseOrderLine belongsTo InventoryItem via polInvtId', () => { + assertAssoc('PurchaseOrderLine', 'BelongsTo', 'polInvtId', 'InventoryItem'); + }); +}); + +describe('association graph: PurchaseOrder chain', () => { + test('PurchaseOrderHeader belongsTo PurchaseOrderVendor via pohPovId', () => { + assertAssoc('PurchaseOrderHeader', 'BelongsTo', 'pohPovId', 'PurchaseOrderVendor'); + }); + // The line→header FK is named "polpoh" — lowercase, no separator — + // a name that comes from the original BACPAC. We mirror it. + test('PurchaseOrderLine belongsTo PurchaseOrderHeader via polpoh', () => { + assertAssoc('PurchaseOrderLine', 'BelongsTo', 'polpoh', 'PurchaseOrderHeader'); + }); +}); + +describe('association graph: Worker.defaultBillingType', () => { + test('Worker belongsTo BillingType via workerDefaultBillType', () => { + assertAssoc('Worker', 'BelongsTo', 'workerDefaultBillType', 'BillingType'); + }); +});