Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`createdAt` / `updatedAt` on every domain entity** (P4-K). New
migration adds two `TIMESTAMPTZ NOT NULL DEFAULT now()` columns to
18 tables (everything except `IdempotencyKey`, which already
tracks its own time fields). All 18 models flip
`timestamps: false` → `true` so Sequelize auto-populates the
columns on every `.create()` / `.update()`. Existing rows are
backfilled to `now()` at apply time; operators with the original
SQL Server timestamps from the Atbash legacy can patch real values
post-migration via a one-off UPDATE.
- **Prometheus `/metrics` endpoint** (P4-J). Exposes prom-client's
default Node.js metrics (event-loop lag, heap, GC, etc.) plus
per-request `http_requests_total{method,route,status}` and
Expand Down
76 changes: 76 additions & 0 deletions app/migrations/20260520000000-timestamps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
//
// Add `createdAt` / `updatedAt` (TIMESTAMPTZ NOT NULL DEFAULT now())
// to every domain table so Sequelize models can flip
// `timestamps: false` → `timestamps: true`.
//
// Why P4-K:
// - Auditability: every row carries its creation + last-modification
// time without each controller having to maintain it by hand.
// - Sync clients: third-party integrations get a reliable
// "what's changed since T" boundary for delta-pull workflows.
// - Observability: SQL ad-hoc analysis on "new customers per day"
// etc. trivially works off `createdAt` rather than a row-counter.
//
// IdempotencyKey is intentionally NOT in this list — it already
// manages its own ikCreatedAt/ikExpiresAt and a parallel
// createdAt/updatedAt pair would be redundant + confusing.
//
// Backfill strategy:
// Existing rows have no recorded history, so we backfill both
// columns to now() at migration-apply time. Operators with the
// original SQL Server timestamps (Atbash legacy) can patch
// real values post-migration via a one-off UPDATE.
//
// Down: simply DROP the two columns from each table. Safe — no
// FKs reference these columns.

'use strict';

const TABLES = [
'ApiKey',
'ApiMaster',
'BillingType',
'Company',
'Customer',
'CustomerPayment',
'InventoryItem',
'InventoryTransactions',
'Invoice',
'InvoiceJob',
'Job',
'ProductEntry',
'PurchaseOrderHeaders',
'PurchaseOrderLines',
'PurchaseOrderVendors',
'TimeEntry',
'VersionInfo',
'Worker',
];

module.exports = {
async up(queryInterface, Sequelize) {
const SCHEMA = 'dbo';
const sequelize = queryInterface.sequelize;
for (const table of TABLES) {
await sequelize.query(`
ALTER TABLE "${SCHEMA}"."${table}"
ADD COLUMN IF NOT EXISTS "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
`);
}
},

async down(queryInterface, Sequelize) {
const SCHEMA = 'dbo';
const sequelize = queryInterface.sequelize;
for (const table of TABLES) {
await sequelize.query(`
ALTER TABLE "${SCHEMA}"."${table}"
DROP COLUMN IF EXISTS "updatedAt",
DROP COLUMN IF EXISTS "createdAt"
`);
}
},
};
2 changes: 1 addition & 1 deletion app/models/apikey.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module.exports = (sequelize, Sequelize) => {
},
{
tableName: 'ApiKey',
timestamps: false,
timestamps: true,
defaultScope: { where: { akArchive: false } }
}
);
Expand Down
2 changes: 1 addition & 1 deletion app/models/apimaster.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = (sequelize, Sequelize) => {
},
{
tableName: 'ApiMaster',
timestamps: false,
timestamps: true,
defaultScope: { where: { amArchive: false } }
}
);
Expand Down
2 changes: 1 addition & 1 deletion app/models/billingtype.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'BillingType',
timestamps: false,
timestamps: true,
defaultScope: { where: { btArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/company.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'Company',
timestamps: false,
timestamps: true,
defaultScope: { where: { compArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/customer.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ module.exports = (sequelize, Sequelize) => {
},
{
tableName: 'Customer',
timestamps: false,
timestamps: true,
defaultScope: { where: { custArch: false } }
}
);
Expand Down
2 changes: 1 addition & 1 deletion app/models/customerpayment.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'CustomerPayment',
timestamps: false,
timestamps: true,
defaultScope: { where: { cpayArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/inventoryitem.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'InventoryItem',
timestamps: false,
timestamps: true,
defaultScope: { where: { invitArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/inventorytransaction.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ module.exports = (sequelize, Sequelize) => {
invtInitId: { field: 'invtInitId', type: Sequelize.INTEGER, allowNull: false },
}, {
tableName: 'InventoryTransactions',
timestamps: false,
timestamps: true,
defaultScope: { where: { invtArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/invoice.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'Invoice',
timestamps: false,
timestamps: true,
defaultScope: { where: { invArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/invoicejob.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'InvoiceJob',
timestamps: false,
timestamps: true,
defaultScope: { where: { injbArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/job.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'Job',
timestamps: false,
timestamps: true,
defaultScope: { where: { jobArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/productentry.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'ProductEntry',
timestamps: false,
timestamps: true,
defaultScope: { where: { penArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/purchaseorderheader.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ module.exports = (sequelize, Sequelize) => {
pohArch: { field: 'pohArch', type: Sequelize.BOOLEAN, defaultValue: false },
}, {
tableName: 'PurchaseOrderHeaders',
timestamps: false,
timestamps: true,
defaultScope: { where: { pohArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/purchaseorderline.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ module.exports = (sequelize, Sequelize) => {
polArch: { field: 'polArch', type: Sequelize.BOOLEAN, defaultValue: false },
}, {
tableName: 'PurchaseOrderLines',
timestamps: false,
timestamps: true,
defaultScope: { where: { polArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/purchaseordervendor.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ module.exports = (sequelize, Sequelize) => {
povArch: { field: 'povArch', type: Sequelize.BOOLEAN, defaultValue: false },
}, {
tableName: 'PurchaseOrderVendors',
timestamps: false,
timestamps: true,
defaultScope: { where: { povArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/timeentry.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'TimeEntry',
timestamps: false,
timestamps: true,
defaultScope: { where: { teArch: false } }
});

Expand Down
2 changes: 1 addition & 1 deletion app/models/versioninfo.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'VersionInfo',
timestamps: false,
timestamps: true,
});

return VersionInfo;
Expand Down
2 changes: 1 addition & 1 deletion app/models/worker.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ module.exports = (sequelize, Sequelize) => {
},
}, {
tableName: 'Worker',
timestamps: false,
timestamps: true,
defaultScope: { where: { workerArch: false } }
});

Expand Down
58 changes: 58 additions & 0 deletions tests/unit/timestamps.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
//
// Verifies every domain model has `timestamps: true` so Sequelize
// auto-populates `createdAt`/`updatedAt` on every write. Paired
// with migration `20260520000000-timestamps.js` which adds the
// underlying columns.

import { describe, test, expect } from 'vitest';

const db = require('../../app/config/db.config.js');

// IdempotencyKey manages its own ikCreatedAt/ikExpiresAt columns
// and is intentionally excluded from the auto-timestamp set.
const MODELS_WITH_TIMESTAMPS = [
'ApiKey',
'ApiMaster',
'BillingType',
'Company',
'Customer',
'CustomerPayment',
'InventoryItem',
'InventoryTransaction',
'Invoice',
'InvoiceJob',
'Job',
'ProductEntry',
'PurchaseOrderHeader',
'PurchaseOrderLine',
'PurchaseOrderVendor',
'TimeEntry',
'VersionInfo',
'Worker',
];

describe('every domain model has timestamps enabled', () => {
test.each(MODELS_WITH_TIMESTAMPS)(
'%s carries timestamps:true on its options',
(modelName) => {
const model = db[modelName];
expect(model, `${modelName} should be defined on db`).toBeDefined();
expect(model.options.timestamps).toBe(true);
},
);

test.each(MODELS_WITH_TIMESTAMPS)(
'%s exposes createdAt / updatedAt attributes on the model',
(modelName) => {
const attrs = db[modelName].rawAttributes;
expect(attrs).toBeDefined();
// Sequelize names them according to the model's defaults
// (camelCase here since none of the models override
// createdAt/updatedAt keys).
expect(attrs.createdAt).toBeDefined();
expect(attrs.updatedAt).toBeDefined();
},
);
});
Loading